Beitrag zur DAV Data Science Challenge: Fraud Detection

0. Einleitung

In diesem Notebook beleuchten wir ein relevantes Projekt, das bei Versicherern häufig anzutreffen ist: Betrugserkennung (engl. Fraud Detection). Hierzu verwenden wir einen KFz-Datensatz aus einer öffentlichen Quelle und werden die notwendigen Schritte zur Etablierung einer automatisierten Betrugserkennung aufzeigen. Das Notebook gliedert sich wie folgt:

Im ersten Abschnitt wird der ausgewählte Datensatz näher untersucht und einer Plausibilitätsprüfung unterzogen werden. Der zweite Abschnitt widmet sich der Knowledge Discovery, bei der interessante Fakten zutage gefördert werden, z.B. dass anormal hohe KFz-Schäden von Yacht-Besitzern sehr wahrscheinlich auf einen Betrug hinweisen. Im dritten Abschnitt widmen wir uns der Konstruktion eines Machine Learning Modells zur Identifikation von Betrugsfällen. In diesem Sinne präsentieren wir nicht nur fertige Lösungen, sondern zeigen den kompletten Entwicklungsweg von der ersten Idee bis zum fertigen Modell auf. Im vierten Abschnitt gehen wir auf die monetären Aspekte einer ML-gestützten Betrugserkennung ein und illustrieren die Vor- und Nachteile zweier Modelle exemplarisch an dem verwendeten Datensatz. Die Ergebnisse werden dann im fünften Abschnit zusammengefasst.

Das Credo, welches sich durch die gesamte Bearbeitung ziehen wird, ist: Data Science mit seinen modernen Methoden der maschinellen Auswertung bietet außergewöhnliche Möglichkeiten für das Aktuariat der Zukunft, kann einen Aktuar mit seinem Sachverstand jedoch nicht ersetzen. Dies wird unter anderem in der Plausibilitätsprüfung deutlich werden. Weiterhin bedarf es bei einem Data Scientisten nicht nur den primären Anwender. Vielmehr müssen die Werkzeuge verstanden und zur Not individuell neu gestaltet werden, wie im dritten Abschnitt deutlich werden wird. Dieses Notebook soll exemplarisch aufzeigen, dass nur eine Synthese aus Aktuariat und Data Science die notwendigen Synnergieeffekte generieren kann, die für Versicherungen in der Zukunft zu einem erhöhten Mehrwert führen werden.

Bemerkungen zum Datensatz

  • Der Datensatz stammt aus der öffentlichen Quelle https://www.kaggle.com/buntyshah/auto-insurance-claims-data
  • Weitere Informationen zu diesem Datensatz sind nicht bekanntgegeben, insbesondere nicht, woher diese Daten stammen.
  • Der Datensatz enthält Schadensfälle einer KFz-Versicherung mit der Anmerkung, ob ein Betrug nachgewiesen wurde oder nicht.

Laden der notwendigen Python Pakete

In [1]:
import numpy as np                                      # numerische Mathematik mittels Arrays und Broadcasting
import pandas as pd                                     # Verwendung von Data Frames ähnlich wie bei relationalen DB
from scipy import stats as st                           # Statistische Operationen wie Hypothesentest etc.

from sklearn.preprocessing import StandardScaler        # Daten Normalisieren
from sklearn.model_selection import train_test_split    # Datensatz-Splitting
from sklearn.preprocessing import OneHotEncoder         # One-Hot-Encoding
from sklearn.preprocessing import LabelEncoder          # simpler Encoder (1,2,3,...)

from sklearn.model_selection import RandomizedSearchCV      # Classifier Optimierung via Random Grid
from sklearn.metrics import precision_recall_fscore_support # Berechnung von Precision, Recall und F1 Score
from sklearn.metrics import classification_report           # Accuracy Metriken

from sklearn.linear_model import LogisticRegression     # Logistische Regression
from sklearn.ensemble import RandomForestClassifier     # Random Forest
from sklearn.naive_bayes import GaussianNB              # Naiver Bayes
from sklearn.svm import SVC                             # Support Vector Machine

from sklearn.decomposition import PCA                        # Hauptkomponentenanalyse
from sklearn.decomposition import FactorAnalysis             # Faktoranalyse
from sklearn.feature_selection import SelectKBest            # Feature Selection K-Best mit Score-Funktion
from sklearn.feature_selection import mutual_info_classif    # Score-Funktion
from sklearn.inspection import permutation_importance        # Feature Importance via Permutation

import tensorflow as tf                                      # Deep Learning Framework
from tensorflow import keras                                 # Deep Learning Framework
from keras.models import Sequential                          # Deep Learning Framework
from keras.layers import Dense, Dropout, BatchNormalization                      # Deep Learning Framework  
from keras.optimizers import SGD, Adam                       # Deep Learning Framework

import warnings ; warnings.simplefilter(action='ignore')     # unwichtige Warnungen ignogieren.

from matplotlib import pyplot as plt                         # Erstellen von allgemeinen Grafiken
import seaborn as sns ; sns.set_style('darkgrid')            # Zusatzpaket zur Datenvisualisierung

from datetime import date                                    # Handling von Datumsangaben mit internem Kalender
C:\Users\p203537\AppData\Local\conda\conda\envs\test_fl\lib\site-packages\tensorflow\python\framework\dtypes.py:516: FutureWarning: Passing (type, 1) or '1type' as a synonym of type is deprecated; in a future version of numpy, it will be understood as (type, (1,)) / '(1,)type'.
  _np_qint8 = np.dtype([("qint8", np.int8, 1)])
C:\Users\p203537\AppData\Local\conda\conda\envs\test_fl\lib\site-packages\tensorflow\python\framework\dtypes.py:517: FutureWarning: Passing (type, 1) or '1type' as a synonym of type is deprecated; in a future version of numpy, it will be understood as (type, (1,)) / '(1,)type'.
  _np_quint8 = np.dtype([("quint8", np.uint8, 1)])
C:\Users\p203537\AppData\Local\conda\conda\envs\test_fl\lib\site-packages\tensorflow\python\framework\dtypes.py:518: FutureWarning: Passing (type, 1) or '1type' as a synonym of type is deprecated; in a future version of numpy, it will be understood as (type, (1,)) / '(1,)type'.
  _np_qint16 = np.dtype([("qint16", np.int16, 1)])
C:\Users\p203537\AppData\Local\conda\conda\envs\test_fl\lib\site-packages\tensorflow\python\framework\dtypes.py:519: FutureWarning: Passing (type, 1) or '1type' as a synonym of type is deprecated; in a future version of numpy, it will be understood as (type, (1,)) / '(1,)type'.
  _np_quint16 = np.dtype([("quint16", np.uint16, 1)])
C:\Users\p203537\AppData\Local\conda\conda\envs\test_fl\lib\site-packages\tensorflow\python\framework\dtypes.py:520: FutureWarning: Passing (type, 1) or '1type' as a synonym of type is deprecated; in a future version of numpy, it will be understood as (type, (1,)) / '(1,)type'.
  _np_qint32 = np.dtype([("qint32", np.int32, 1)])
C:\Users\p203537\AppData\Local\conda\conda\envs\test_fl\lib\site-packages\tensorflow\python\framework\dtypes.py:525: FutureWarning: Passing (type, 1) or '1type' as a synonym of type is deprecated; in a future version of numpy, it will be understood as (type, (1,)) / '(1,)type'.
  np_resource = np.dtype([("resource", np.ubyte, 1)])
C:\Users\p203537\AppData\Local\conda\conda\envs\test_fl\lib\site-packages\tensorboard\compat\tensorflow_stub\dtypes.py:541: FutureWarning: Passing (type, 1) or '1type' as a synonym of type is deprecated; in a future version of numpy, it will be understood as (type, (1,)) / '(1,)type'.
  _np_qint8 = np.dtype([("qint8", np.int8, 1)])
C:\Users\p203537\AppData\Local\conda\conda\envs\test_fl\lib\site-packages\tensorboard\compat\tensorflow_stub\dtypes.py:542: FutureWarning: Passing (type, 1) or '1type' as a synonym of type is deprecated; in a future version of numpy, it will be understood as (type, (1,)) / '(1,)type'.
  _np_quint8 = np.dtype([("quint8", np.uint8, 1)])
C:\Users\p203537\AppData\Local\conda\conda\envs\test_fl\lib\site-packages\tensorboard\compat\tensorflow_stub\dtypes.py:543: FutureWarning: Passing (type, 1) or '1type' as a synonym of type is deprecated; in a future version of numpy, it will be understood as (type, (1,)) / '(1,)type'.
  _np_qint16 = np.dtype([("qint16", np.int16, 1)])
C:\Users\p203537\AppData\Local\conda\conda\envs\test_fl\lib\site-packages\tensorboard\compat\tensorflow_stub\dtypes.py:544: FutureWarning: Passing (type, 1) or '1type' as a synonym of type is deprecated; in a future version of numpy, it will be understood as (type, (1,)) / '(1,)type'.
  _np_quint16 = np.dtype([("quint16", np.uint16, 1)])
C:\Users\p203537\AppData\Local\conda\conda\envs\test_fl\lib\site-packages\tensorboard\compat\tensorflow_stub\dtypes.py:545: FutureWarning: Passing (type, 1) or '1type' as a synonym of type is deprecated; in a future version of numpy, it will be understood as (type, (1,)) / '(1,)type'.
  _np_qint32 = np.dtype([("qint32", np.int32, 1)])
C:\Users\p203537\AppData\Local\conda\conda\envs\test_fl\lib\site-packages\tensorboard\compat\tensorflow_stub\dtypes.py:550: FutureWarning: Passing (type, 1) or '1type' as a synonym of type is deprecated; in a future version of numpy, it will be understood as (type, (1,)) / '(1,)type'.
  np_resource = np.dtype([("resource", np.ubyte, 1)])
Using TensorFlow backend.

1. Datensatz laden und Qualität prüfen

Anmerkung: Unter Umständen muss der Dateipfad angepasst werden! Es sollte aber ausreichen, wenn sich die CSV-Datei im gleichen Ordner befindet, wie das Jupyter Notebook.

Zunächst verschaffen wir uns einen Überblick über die Datentypen und fehlende Daten.

In [2]:
DF = pd.read_csv("D:/public_data/kaggle-buntyshah_insurance_claims_fraud.csv")
DF.info(verbose=True)
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 1000 entries, 0 to 999
Data columns (total 40 columns):
months_as_customer             1000 non-null int64
age                            1000 non-null int64
policy_number                  1000 non-null int64
policy_bind_date               1000 non-null object
policy_state                   1000 non-null object
policy_csl                     1000 non-null object
policy_deductable              1000 non-null int64
policy_annual_premium          1000 non-null float64
umbrella_limit                 1000 non-null int64
insured_zip                    1000 non-null int64
insured_sex                    1000 non-null object
insured_education_level        1000 non-null object
insured_occupation             1000 non-null object
insured_hobbies                1000 non-null object
insured_relationship           1000 non-null object
capital-gains                  1000 non-null int64
capital-loss                   1000 non-null int64
incident_date                  1000 non-null object
incident_type                  1000 non-null object
collision_type                 1000 non-null object
incident_severity              1000 non-null object
authorities_contacted          1000 non-null object
incident_state                 1000 non-null object
incident_city                  1000 non-null object
incident_location              1000 non-null object
incident_hour_of_the_day       1000 non-null int64
number_of_vehicles_involved    1000 non-null int64
property_damage                1000 non-null object
bodily_injuries                1000 non-null int64
witnesses                      1000 non-null int64
police_report_available        1000 non-null object
total_claim_amount             1000 non-null int64
injury_claim                   1000 non-null int64
property_claim                 1000 non-null int64
vehicle_claim                  1000 non-null int64
auto_make                      1000 non-null object
auto_model                     1000 non-null object
auto_year                      1000 non-null int64
fraud_reported                 1000 non-null object
_c39                           0 non-null float64
dtypes: float64(2), int64(17), object(21)
memory usage: 312.6+ KB

Erkenntnisse: Es scheint, als ob das Feature _c39 nur aus Leerzeilen besteht. Alle anderen Features scheinen vollständig zu sein. Der Datensatz enthält sehr viele nominal/ordinal Skalierte Merkmale und sehr wenig kontinuierliche. Wir untersuchen im Folgenden die Merkmalsausprägungen pro Feature.

In [3]:
for col in DF.columns:
    print(DF[col].value_counts())
    print()
194    8
285    7
140    7
230    7
128    7
254    7
101    7
210    7
156    6
239    6
245    6
289    6
246    6
61     6
255    6
257    6
65     6
147    6
259    6
290    6
222    6
107    6
295    6
163    6
126    6
134    5
108    5
266    5
103    5
269    5
      ..
394    1
392    1
390    1
82     1
83     1
389    1
386    1
385    1
381    1
379    1
90     1
377    1
92     1
375    1
373    1
372    1
183    1
366    1
364    1
100    1
359    1
355    1
354    1
352    1
109    1
347    1
113    1
337    1
117    1
0      1
Name: months_as_customer, Length: 391, dtype: int64

43    49
39    48
41    45
34    44
30    42
31    42
38    42
37    41
33    39
32    38
40    38
29    35
46    33
35    32
36    32
42    32
44    32
28    30
45    26
26    26
48    25
47    24
27    24
57    16
25    14
49    14
55    14
50    13
53    13
61    10
24    10
54    10
60     9
51     9
58     8
56     8
23     7
21     6
59     5
62     4
52     4
64     2
63     2
22     1
20     1
19     1
Name: age, dtype: int64

116735    1
107181    1
430794    1
115399    1
328387    1
824116    1
492224    1
663190    1
936638    1
193213    1
347984    1
674485    1
740019    1
326322    1
836272    1
651948    1
484200    1
182953    1
154280    1
793948    1
563878    1
150181    1
699044    1
246435    1
500384    1
924318    1
776860    1
873114    1
463513    1
125591    1
         ..
904191    1
544225    1
206213    1
873859    1
515457    1
658816    1
556415    1
343421    1
261905    1
804219    1
118137    1
521592    1
431478    1
290162    1
521585    1
853360    1
935277    1
439660    1
464234    1
154982    1
761189    1
218684    1
689500    1
398683    1
163161    1
218456    1
179538    1
357713    1
247116    1
296960    1
Name: policy_number, Length: 1000, dtype: int64

1992-08-05    3
1992-04-28    3
2006-01-01    3
2013-12-25    2
1997-05-15    2
1999-04-07    2
2009-11-08    2
1995-09-19    2
1991-07-20    2
2014-07-27    2
1993-08-30    2
2007-05-06    2
1997-11-15    2
1999-09-29    2
2005-09-21    2
1992-04-14    2
1996-09-21    2
2010-03-11    2
1991-08-22    2
1992-01-05    2
1997-07-14    2
1991-12-14    2
1998-01-29    2
1990-09-20    2
1995-12-07    2
1997-02-03    2
1999-12-07    2
2004-08-09    2
2003-03-09    2
2000-05-04    2
             ..
2014-04-25    1
2005-11-20    1
2007-03-14    1
2001-12-19    1
2009-02-08    1
2014-08-30    1
1991-10-29    1
1992-10-14    1
1997-02-05    1
2005-04-17    1
2006-11-25    1
1996-01-04    1
1999-07-22    1
1992-04-07    1
2009-03-05    1
2000-12-27    1
2007-10-25    1
2012-01-05    1
2011-07-31    1
2004-04-15    1
2009-04-27    1
1999-05-29    1
2000-06-18    1
2015-02-22    1
2009-04-10    1
2001-11-06    1
1992-11-27    1
2009-08-03    1
2012-10-09    1
2010-03-06    1
Name: policy_bind_date, Length: 951, dtype: int64

OH    352
IL    338
IN    310
Name: policy_state, dtype: int64

250/500     351
100/300     349
500/1000    300
Name: policy_csl, dtype: int64

1000    351
500     342
2000    307
Name: policy_deductable, dtype: int64

1374.22    2
1558.29    2
1389.13    2
1073.83    2
1074.07    2
1281.25    2
1215.36    2
1524.45    2
1362.87    2
1139.00    1
809.11     1
1576.41    1
954.18     1
1332.07    1
1379.93    1
1119.29    1
1294.93    1
1706.79    1
855.14     1
1000.06    1
1259.02    1
1173.21    1
903.32     1
1515.18    1
1611.83    1
929.70     1
1142.62    1
1969.63    1
1462.76    1
894.40     1
          ..
1399.26    1
1132.74    1
848.07     1
1454.42    1
991.39     1
1234.69    1
1561.41    1
1307.68    1
1451.54    1
1008.79    1
1311.30    1
1550.53    1
1108.97    1
976.37     1
1575.86    1
883.31     1
1441.60    1
1402.75    1
1082.36    1
1340.56    1
1181.64    1
1396.83    1
1215.85    1
1281.43    1
617.11     1
1268.79    1
1558.86    1
722.66     1
1302.40    1
1212.00    1
Name: policy_annual_premium, Length: 991, dtype: int64

 0           798
 6000000      57
 5000000      46
 4000000      39
 7000000      29
 3000000      12
 8000000       8
 9000000       5
 2000000       3
 10000000      2
-1000000       1
Name: umbrella_limit, dtype: int64

446895    2
456602    2
477695    2
469429    2
431202    2
453277    1
443625    1
469653    1
471704    1
453274    1
619166    1
453265    1
477856    1
608929    1
459428    1
459429    1
479913    1
432786    1
469646    1
459407    1
610989    1
432781    1
619148    1
438923    1
606858    1
620819    1
459889    1
604804    1
440961    1
470128    1
         ..
611723    1
601425    1
439690    1
431496    1
603527    1
455810    1
617858    1
466303    1
441726    1
605141    1
464646    1
474167    1
470389    1
441716    1
441714    1
466289    1
478575    1
464237    1
619884    1
466283    1
432399    1
464230    1
614274    1
435552    1
620493    1
468313    1
474360    1
476502    1
460895    1
454656    1
Name: insured_zip, Length: 995, dtype: int64

FEMALE    537
MALE      463
Name: insured_sex, dtype: int64

JD             161
High School    160
Associate      145
MD             144
Masters        143
PhD            125
College        122
Name: insured_education_level, dtype: int64

machine-op-inspct    93
prof-specialty       85
tech-support         78
exec-managerial      76
sales                76
craft-repair         74
transport-moving     72
other-service        71
priv-house-serv      71
armed-forces         69
adm-clerical         65
protective-serv      63
handlers-cleaners    54
farming-fishing      53
Name: insured_occupation, dtype: int64

reading           64
paintball         57
exercise          57
bungie-jumping    56
golf              55
camping           55
movies            55
kayaking          54
yachting          53
hiking            52
video-games       50
base-jumping      49
skydiving         49
board-games       48
polo              47
chess             46
dancing           43
sleeping          41
cross-fit         35
basketball        34
Name: insured_hobbies, dtype: int64

own-child         183
other-relative    177
not-in-family     174
husband           170
wife              155
unmarried         141
Name: insured_relationship, dtype: int64

0         508
46300       5
68500       4
51500       4
48900       3
51100       3
52600       3
47600       3
51700       3
63600       3
38600       3
49700       3
67800       3
49900       3
45500       3
51400       3
59600       3
43700       3
52800       3
45700       3
63100       3
55600       3
29300       3
46700       3
35100       3
56700       3
58500       3
53200       3
75800       3
42900       3
         ... 
88800       1
72400       1
42700       1
34500       1
51900       1
66300       1
47800       1
38700       1
57000       1
52900       1
48800       1
56800       1
40600       1
58000       1
53900       1
75400       1
42300       1
67200       1
59000       1
58900       1
50800       1
37100       1
42600       1
38500       1
60700       1
47700       1
94800       1
90700       1
100500      1
54800       1
Name: capital-gains, Length: 338, dtype: int64

 0        475
-53700      5
-50300      5
-31700      5
-49200      4
-61400      4
-53800      4
-51000      4
-31400      4
-45300      4
-32800      4
-44500      3
-51500      3
-55600      3
-56200      3
-49500      3
-49000      3
-39300      3
-41400      3
-45800      3
-48800      3
-50000      3
-68200      3
-42700      3
-67400      3
-78600      3
-49400      3
-46900      3
-66200      3
-61000      3
         ... 
-13200      1
-63500      1
-42400      1
-21500      1
-59900      1
-38400      1
-73900      1
-51900      1
-54400      1
-59700      1
-71500      1
-39400      1
-43500      1
-13800      1
-53200      1
-17000      1
-78300      1
-74200      1
-66000      1
-36300      1
-26400      1
-32200      1
-89400      1
-49600      1
-45500      1
-32600      1
-46800      1
-50600      1
-40800      1
-43900      1
Name: capital-loss, Length: 354, dtype: int64

2015-02-02    28
2015-02-17    26
2015-01-07    25
2015-02-04    24
2015-01-24    24
2015-01-10    24
2015-01-19    23
2015-01-08    22
2015-01-13    21
2015-01-30    21
2015-02-12    20
2015-02-22    20
2015-02-06    20
2015-01-31    20
2015-01-14    19
2015-02-21    19
2015-01-01    19
2015-02-23    19
2015-01-21    19
2015-01-12    19
2015-01-03    18
2015-01-18    18
2015-02-01    18
2015-02-25    18
2015-02-28    18
2015-02-14    18
2015-01-20    18
2015-02-24    17
2015-01-09    17
2015-01-06    17
2015-02-08    17
2015-02-26    17
2015-01-16    16
2015-02-05    16
2015-02-13    16
2015-02-15    16
2015-02-16    16
2015-01-15    15
2015-02-18    15
2015-01-17    15
2015-01-28    15
2015-02-20    14
2015-02-27    14
2015-01-22    14
2015-01-27    13
2015-01-23    13
2015-02-03    13
2015-02-09    13
2015-03-01    12
2015-01-04    12
2015-01-02    11
2015-01-26    11
2015-01-29    11
2015-02-07    10
2015-01-25    10
2015-02-11    10
2015-02-19    10
2015-02-10    10
2015-01-11     9
2015-01-05     7
Name: incident_date, dtype: int64

Multi-vehicle Collision     419
Single Vehicle Collision    403
Vehicle Theft                94
Parked Car                   84
Name: incident_type, dtype: int64

Rear Collision     292
Side Collision     276
Front Collision    254
?                  178
Name: collision_type, dtype: int64

Minor Damage      354
Total Loss        280
Major Damage      276
Trivial Damage     90
Name: incident_severity, dtype: int64

Police       292
Fire         223
Other        198
Ambulance    196
None          91
Name: authorities_contacted, dtype: int64

NY    262
SC    248
WV    217
NC    110
VA    110
PA     30
OH     23
Name: incident_state, dtype: int64

Springfield    157
Arlington      152
Columbus       149
Northbend      145
Hillsdale      141
Riverwood      134
Northbrook     122
Name: incident_city, dtype: int64

5474 Weaver Hwy          1
8862 Maple Ridge         1
7825 1st Ridge           1
8336 1st Ridge           1
9657 5th Ave             1
7544 Washington Ave      1
8489 Pine Hwy            1
4627 Elm Ridge           1
5383 Maple Drive         1
3639 Flute Hwy           1
4755 1st St              1
4857 Weaver St           1
8995 1st Ave             1
6375 2nd Lane            1
8805 Cherokee Drive      1
4672 MLK St              1
8917 Cherokee Lane       1
7733 Britain Lane        1
6678 Weaver Drive        1
3094 Best Lane           1
3693 Pine Ave            1
8667 Weaver Lane         1
9794 Embaracadero St     1
7846 Andromedia Drive    1
2889 Francis St          1
3508 Washington St       1
3777 Maple Ave           1
9153 3rd Hwy             1
3495 Britain Drive       1
9910 Maple Ave           1
                        ..
4119 Texas St            1
6206 3rd Ridge           1
8404 Embaracadero St     1
6888 Elm Ridge           1
4390 4th Drive           1
7909 Andromedia Hwy      1
5499 Flute Ridge         1
3488 Flute Lane          1
7477 MLK Drive           1
3814 Britain Drive       1
3102 Apache St           1
2199 Texas Drive         1
5663 Oak Lane            1
8586 1st Ridge           1
9742 5th Ridge           1
7240 5th Ridge           1
2697 Oak Drive           1
8689 Maple Hwy           1
6315 2nd Lane            1
1705 Weaver St           1
2757 4th Hwy             1
3966 Francis Ridge       1
6848 Elm Hwy             1
6931 Elm St              1
3443 Maple Ridge         1
9787 Andromedia Ave      1
7495 Washington Ave      1
4020 Best Drive          1
3422 Flute St            1
1273 Rock Lane           1
Name: incident_location, Length: 1000, dtype: int64

17    54
3     53
0     52
23    51
16    49
4     46
13    46
10    46
6     44
9     43
14    43
21    42
18    41
7     40
19    40
12    40
15    39
22    38
8     36
20    34
5     33
2     31
11    30
1     29
Name: incident_hour_of_the_day, dtype: int64

1    581
3    358
4     31
2     30
Name: number_of_vehicles_involved, dtype: int64

?      360
NO     338
YES    302
Name: property_damage, dtype: int64

0    340
2    332
1    328
Name: bodily_injuries, dtype: int64

1    258
2    250
0    249
3    243
Name: witnesses, dtype: int64

?      343
NO     343
YES    314
Name: police_report_available, dtype: int64

59400    5
75400    4
60600    4
2640     4
58500    4
44200    4
4320     4
3190     4
70290    4
70400    4
5940     4
58300    3
53460    3
7080     3
79800    3
4620     3
64800    3
77880    3
74200    3
53280    3
6820     3
50800    3
69300    3
84590    3
6600     3
56160    3
55000    3
77440    3
64100    3
53400    3
        ..
65100    1
55500    1
67140    1
95810    1
68580    1
45630    1
48780    1
53820    1
92730    1
47760    1
75960    1
77660    1
4730     1
54160    1
57500    1
28440    1
5760     1
78500    1
72900    1
47740    1
8030     1
73320    1
59000    1
31350    1
54900    1
57970    1
41580    1
45180    1
3690     1
71680    1
Name: total_claim_amount, Length: 763, dtype: int64

0        25
480       7
640       7
580       5
6340      5
660       5
780       5
13520     5
860       5
1180      5
5540      5
1300      4
1240      4
7420      4
940       4
560       4
900       4
4420      4
10540     4
6630      4
600       4
680       4
5000      4
5550      4
430       3
7080      3
6410      3
5710      3
630       3
590       3
         ..
11560     1
2850      1
8150      1
5610      1
6970      1
3900      1
6610      1
6590      1
11860     1
8640      1
7620      1
7000      1
12740     1
1200      1
7640      1
220       1
570       1
13300     1
6880      1
13780     1
14160     1
6990      1
20700     1
17880     1
15180     1
16820     1
5960      1
10840     1
8000      1
5530      1
Name: injury_claim, Length: 638, dtype: int64

0        19
860       6
660       5
480       5
10000     5
650       5
11080     5
640       5
680       4
5720      4
960       4
6620      4
1180      4
9360      4
580       4
7000      4
6410      4
13860     4
780       4
6330      4
10520     4
5310      4
6340      4
840       4
5340      4
590       4
11100     4
4420      4
7040      4
9540      3
         ..
5920      1
16020     1
11260     1
11840     1
7660      1
5700      1
5560      1
4580      1
6880      1
13080     1
1470      1
8640      1
7680      1
9020      1
7620      1
4550      1
6970      1
1480      1
2970      1
4920      1
15820     1
570       1
15380     1
10060     1
7990      1
17880     1
1500      1
14080     1
7850      1
21630     1
Name: property_claim, Length: 626, dtype: int64

5040     7
3360     6
3600     5
44800    5
33600    5
4720     5
52080    5
42720    4
46800    4
41760    4
38850    4
2940     4
35000    4
41580    4
45360    4
38010    3
3850     3
56320    3
46320    3
58500    3
51120    3
4340     3
3760     3
46160    3
2320     3
2730     3
49840    3
44030    3
2100     3
3290     3
        ..
58950    1
45440    1
34370    1
60720    1
47680    1
50000    1
41310    1
39480    1
47670    1
55860    1
34380    1
67440    1
6090     1
56960    1
63090    1
4080     1
26720    1
49770    1
57960    1
30310    1
47760    1
39680    1
30520    1
61020    1
43610    1
46680    1
3640     1
34320    1
40530    1
51200    1
Name: vehicle_claim, Length: 726, dtype: int64

Dodge         80
Suburu        80
Saab          80
Nissan        78
Chevrolet     76
Ford          72
BMW           72
Toyota        70
Audi          69
Volkswagen    68
Accura        68
Jeep          67
Mercedes      65
Honda         55
Name: auto_make, dtype: int64

RAM               43
Wrangler          42
A3                37
Neon              37
MDX               36
Jetta             35
Passat            33
Legacy            32
A5                32
Pathfinder        31
Malibu            30
Camry             28
Forrestor         28
92x               28
F150              27
95                27
E400              27
93                25
Grand Cherokee    25
Tahoe             24
Maxima            24
Escape            24
X5                23
Ultima            23
Civic             22
Silverado         22
Highlander        22
Fusion            21
Corolla           20
CRV               20
Impreza           20
TL                20
ML350             20
C300              18
3 Series          18
X6                16
M5                15
Accord            13
RSX               12
Name: auto_model, dtype: int64

1995    56
1999    55
2005    54
2011    53
2006    53
2007    52
2003    51
2010    50
2009    50
2013    49
2002    49
2015    47
1997    46
2012    46
2008    45
2014    44
2001    42
2000    42
1998    40
2004    39
1996    37
Name: auto_year, dtype: int64

N    753
Y    247
Name: fraud_reported, dtype: int64

Series([], Name: _c39, dtype: int64)

Erkenntnisse:

  • Das Feature policy_bind_date enthält mehrere Elemente in einem Feld; der Datensatz ist nicht in Nullter Normalform.
  • Das Feature policy_csl ist in seiner Beschreibung nicht eindeutig (vermutlich Body/Property-Damage-Limit, könnte aber auch andersherum sein; eine Vermutung genügt nicht für eine sinnvolle Analyse) und wird daher aus dem Datensatz entfernt.
  • Das Feature incident_date enthält mehrere Elemente in einem Feld; der Datensatz ist nicht in Nullter Normalform.
  • Das Feature collision_type enthält das Element ?, welches "Unbekannt" oder Null (Eingabe vergessen) bedeuten kann.
  • Das Feature incident_location mehrere Elemente in einem Feld; der Datensatz ist nicht in Nullter Normalform.
  • Das Feature property_damage enthält das Element ?, welches "Unbekannt" oder Null (Eingabe vergessen) bedeuten kann.
  • Das Feature police_report_available enthält das Element ?, welches "Unbekannt" oder Null (Eingabe vergessen) bedeuten kann.

Bei tiefergehender Prüfung fällt auf:

  • Die Ortsangaben (ZIP, City, State, Street) sind nicht plausibel:
  • Viele ZIP Codes existieren z.B. in den USA nicht und verweisen laut Recherche auf Orte in Singapur und Portugal (z.B. 446895 liegt in Portugal, 456602 in Singapur, 431202 in Russland)
  • Viele Straßen liegen nicht in den angegebenen Bundesstaaten
  • Für die Stadt Arlington werden z.B. mehrere Bundesstaaten angegeben (s. folgendes Diagramm), obwohl Arlington nicht in South Carolina (SC), Pensylvania (PA), West-Viginia (WV) nicht existiert.
In [4]:
DF[DF.incident_city == 'Arlington'].incident_state.value_counts().plot(kind='pie', title='States containing Arlington')
plt.show()

Dies lässt zwei mögliche Schlüsse zu: Entweder ist der Datensatz anonymisiert oder gefälscht/künstlich erzeugt!

Bereinigen des Datensatzes

_c39 hat keine Einträge und die Bedeutung von policy_csl ist ohne weitere Informationen nicht eindeutig. Daher werden beide Werte aus dem Datensatz entfernt! Zusätzlich bringen wir die Tabelle in die nullte Normalform, d.h. wir sorgen dafür, dass die Werte atomar vorliegen. Dies ist bei den Datumsangaben noch nicht realisiert. Diese werden also in Tag/Monat/Jahr gesplittet. Das ganze wird als Funktion realisiert, da wir die gleichen Routinen später noch einmal brauchen werden.

In [5]:
def cleanup(DF):
    DF = DF.drop(columns=['_c39'])
    DF = DF.drop(columns=['policy_csl'])

    ### Erzeugen einer Nullten Normalform
    AuxList = []
    for val in DF['policy_bind_date']:
        AuxList.append(str(val).split('-'))
    AuxArray = np.array(AuxList, dtype='int64').reshape(-1,3)
    DF['policy_bind_year']  = AuxArray[:,0]
    DF['policy_bind_month'] = AuxArray[:,1]
    DF['policy_bind_day']   = AuxArray[:,2]
    DF = DF.drop(columns=['policy_bind_date'])  

    AuxList = []
    for val in DF['incident_date']:
        AuxList.append(str(val).split('-'))
    AuxArray = np.array(AuxList, dtype='int64').reshape(-1,3)
    DF['incident_year']  = AuxArray[:,0] 
    DF['incident_month'] = AuxArray[:,1]
    DF['incident_day']   = AuxArray[:,2]                               
    DF = DF.drop(columns=['incident_date'])  

    AuxList = []
    for val in DF['incident_location']:
        Adress = (str(val).split(' '))
        AuxList.append([Adress[0], ' '.join(Adress[1:]) ])
    AuxArray = np.array(AuxList).reshape(-1,2)
    DF['incident_location_no']     = AuxArray[:,0].astype('int64')
    DF['incident_location_street'] = AuxArray[:,1]
    DF = DF.drop(columns=['incident_location'])
    return DF

DF = cleanup(DF)

Data Augmentation

Anstelle der Datumsangaben führen wir noch ein äquivalentes Feature ein, indem wir ein Datum in "Tage seit Referenz" umrechnen. Für policy_bind verwenden wir als Referenz das am weitesten zurückliegende Datum. Für incident verwenden wir die Tage im laufenden Jahr, da hier nur Angaben aus dem Jahr 2015 existieren. Wir könnten auch das früheste Datum von policy_bind als Referenz hierfür verwenden, würden dann aber nur einen konstanten Shift +k für jeden Wert schaffen (diese Transformation enthält dann keine Informationen). Zudem würden die Werte bei der späteren Skalierung diesen Shift ohnehin wieder verlieren.

In [6]:
#Incident Dates und Policy Bind Dates in Tage seit Referenz umrechnen 

def augment(DF):
    Sort   = DF.sort_values(by=['policy_bind_year','policy_bind_month','policy_bind_day'])
    RefRow = Sort.iloc[0,:]
    y      = RefRow['policy_bind_year']
    m      = RefRow['policy_bind_month']
    d      = RefRow['policy_bind_day']
    date0  = date(y,m,d)

    Deltas = []
    for id in range(len(DF)):
        AuxDF = DF.iloc[id,:]
        y     = AuxDF['policy_bind_year']
        m     = AuxDF['policy_bind_month']
        d     = AuxDF['policy_bind_day']
        date1 = date(y,m,d)
        Deltas.append( (date1-date0).days )
    DF['policy_bind_delta'] = Deltas

    Deltas = []
    date0  = date(2015,1,1)
    for id in range(len(DF)):
        AuxDF = DF.iloc[id,:]
        y     = AuxDF['incident_year']
        m     = AuxDF['incident_month']
        d     = AuxDF['incident_day']
        date1 = date(y,m,d)
        Deltas.append( (date1-date0).days )
    DF['incident_delta'] = Deltas

    DF = DF.drop(columns=['incident_year'])
    return DF

DF = augment(DF)

Insgesamt sind einige Ungereimtheiten aufgefallen, die auf eine künstlicher Erzeugung oder aber auf eine Annonymisierung des Datensatzes hinweisen. Für eine weitere Verarbeitung (Knowledge Discovery und Prediction) musste der Datensatz optimiert werden. Im folgenden Abschnitt wird dieser bereinigte Datensatz nun dazu verwendet werden, latente Informationen und wertvolles Kundenwissen zu extrahieren.

2. Auswertung des Datensatzes (Knowledge Discovery)

Zunächst einmal schauen wir uns die Merkmalsausprägungen an. Wir wählen für jeden Datentyp passende Visualisierungen: Nominale/Ordinale Merkmale werden im Kreisdiagramm, diskrete aber kardinale Merkmale werden in einem Histogramm und kontinuierliche Merkmale mit einer Kerndichteschätzung dargestellt.

In [7]:
Columns_float  = DF.select_dtypes(include=['float64']).columns
Columns_int    = DF.select_dtypes(include=['int64']).columns
Columns_object = DF.select_dtypes(include=['object']).columns

print("Nominale/Ordinale Merkmale")
for col in Columns_object:
    DF[col].value_counts().plot(kind='pie', title=col, figsize=(15,5))
    plt.show()

print("Diskret-Kardinale Merkmale")
for col in Columns_int:
    DF[col].plot(kind='hist', title=col, figsize=(15,5))
    plt.show()

print("Kontinuierlich-Kardinale Merkmale")
for col in Columns_float:
    DF[col].plot(kind='kde', title=col, figsize=(15,5))
    plt.show()
Nominale/Ordinale Merkmale
Diskret-Kardinale Merkmale
Kontinuierlich-Kardinale Merkmale

Erkenntnisse auf Basis der Daten:

  • Frauen sind deskriptiv häufiger in Unfällen involviert
  • Unfallwahrscheinlichkeit ist unabhängig vom Bildungsgrad, Beruf, Hobbys (Risikobereitschaft) und Familienstand
  • Die meisten Schadensfälle entstehen durch Kollisionen (ein oder mehrere KFz)
  • Bagatellschäden sind selten. Am häufigsten sind geringe Schäden (größer als Bagetell)
  • Es ist sehr selten, dass keine Behörden involviert werden
  • Unfällhäufigkeit steht nicht im Zusammenhang mit Automarke oder -modell
  • In einem Viertel der Fälle liegt ein Betrug vor

  • Bis 300 Monaten Kundschaft (~Fahrerfahrung) steigt die Unfallwahrscheinlichkeit

  • Ab 300 Monaten Kundschaft (~Fahrerfahrung) sinkt die Unfallwahrscheinlichkeit
  • Am häufigsten sind Fahrer zwischen 30 und 40 Jahren betroffen
  • Für nahezu alle Unfälle ist kein Umbrella Limit festgesetzt
  • Die Unfallwahrscheinlichkeit ist während des Berufsverkehrs und nachts am höchsten
  • Am häufigsten ist nur ein Auto involviert
  • Wenn mehrere KFz involviert sind, dann sind es wahrscheinlich eher 3 als nur 2
  • Die Gesamtforderung ist entweder sehr gering oder normalverteilt mit Mittelwert zwischen 60k und 70k
  • Der meiste Anteil dieser Forderung entstammt der KFz-Kosten.
  • Am häufigsten passieren Unfälle, wenn die Versicherung im Winter (Dezember, Januar) greift
  • Unfälle passieren am häufigsten bei Kunden mit jährlicher Prämie von 1000 bis 1500 Euro

Anmerkungen zu den Erkenntnissen:

  • Die Verteilung der Daten erscheint seltsam, viele der ordinalen Merkmale sind nahezu perfekt gleichverteilt, viele der kardinalen Daten weisen (mit Ausnahmewerten) eine angenäherte Normalverteilung auf. I.a.W.: Die Aufteilung ist zu "gut"
  • Die Monate als Kunde sind sehr hoch. Eine deutlich Ballung der Werte in den unteren Monaten ist zu erwarten und Werte von ~480 Monaten = 40 Jahren sollten sehr selten auftreten
  • Die Anzahl der involvierten Fahrzeuge erscheint nicht plausibel: Kasko-Schäden können die hohe Anzahl von einem Fahrzeug erklären, aber dass deutlich öfter drei Fahrzeuge als zwei involviert sind, erscheint falsch
  • Eine steigende Unfallwahrscheinlichkeit bis 300 Monate Kundschaft erscheint ebenfalls falsch. Die Statistiken im deutschen Markt zeigen nach Mitte 20 (somit also sogar mit Führerschein ab 16 bei ~120-150 Monaten) ein Abfallen der Unfallwahrscheinlichkeit, die erst wieder im Rentenalter ansteigt

An dieser Stelle sind weitere Indikationen zutage getreten, die eine weitere Plausibilitätsprüfung nötig machen.

Plausibilitätsprüfung

In [ ]:
fig, axarr = plt.subplots(nrows=2, ncols=5, figsize=(15,7))
fig_id=0
for col in ['policy_state','insured_education_level', 'insured_occupation',
            'insured_hobbies', 'insured_relationship', 'incident_city',
            'property_damage', 'police_report_available', 'auto_make',
            'auto_model']:
    DF[col].value_counts().plot(kind='pie', title=col, ax=axarr[fig_id//5,fig_id%5])
    fig_id +=1
plt.show()

Fast alle nominalskalierten Merkmale weisen eine perfekte Gleichverteilung auf, die über so viele Merkmale hinweg nicht realistisch ist. Daraus kann gefolgert werden, dass dieser Datensatz künstlich generiert wurde oder dass dieser ein sehr gut ausgewälter Teildatensatz aus einem größeren Datenbestand darstellt. Bei den kardinalskalierten Merkmalen sind sehr häufig Gleichverteilungen zu erkennen bzw. bei einigen Merkmalen Normalverteilungen mit zusätzlichen Werten außerhalb, wie bereits oben erwähnt.

Zusammen mit den falschen Adressen und in Kombination mit der fachlichen Prüfung liegt die Vermutung nahe, dass der Datensatz konstruiert worden ist. Auf Basis dieser Vermutung und einer anschließenden Suche haben wir eine Version gefunden, die für eine Watson-Schulung verwendet wurde (https://bookdown.org/caoying4work/watsonstudio-workshop/auto.html). Wir gehen davon aus, dass dieser Datensatz zu Schulungszewecken künstlich generiert wurde und anschließend seinen Weg in öffentliche Repositories wie z.B. Kaggle fand. Es ist interessant, dass dies keinem Data Scientisten, der bisher sein Notebook auf Kaggle veröffentlicht hat, aufgefallen zu sein scheint.

Wir lernen daraus: Bevor wir loslegen, sollte immer eine Plausibilitätsprüfung durch Personen mit entsprechendem fachlichen Know-How erfolgen. Auch wenn Data Science und Machine Learning wertvolle Instrumente zur Verfügung stellen, genügt ein reines Anwenden von ML-Algorithmen nicht, um sinnvolle Ergebnisse zu produzieren. Vielmehr muss (fachliches) Know-How genutzt werden, um zu prüfen, ob die Daten plausibel sind, welche Daten verwendet werden (dürfen) und wie die Ergebnisse zu interpretieren sind. Oder, um es auf den Punkt zu bringen: Data Science alleine genügt nicht, es muss (im Versicherungskontext) ebenfalls aktuarielles Know-How vorhanden sein, damit die Ergebnisse belastbar werden.

Wir werden aber weiterhin mit diesen Datensatz arbeiten um Knowledge-Discovery und Prediction zu demonstrieren, wenn der Datensatz "echt" wäre.

Gruppierte Auswertung

Im folgenden werden statistische Merkmale in vielen Kombinationen in gruppierten Boxplot-Diagrammen ausgewertet. Hierdurch lassen sich viele Zusammenhänge erkennen, die eine größere semantische Information besitzen, als die Betrachtung von z.B. Korrelationen.

In [ ]:
for col1 in Columns_object:
    print('\n\n', col1, '\n\n')
    for col2 in list(Columns_int)+list(Columns_float):
        fig,axarr = plt.subplots(nrows=1, figsize=(15,5))
        sns.boxplot(x=col1, y=col2, hue='fraud_reported', ax=axarr, data=DF)
        plt.tight_layout()
        plt.show()

 policy_state 



 insured_sex 



 insured_education_level 



 insured_occupation 


Erkenntnisse:

  • Erst ab ca. 50k-60k Euro Gesamtforderung müssen Betrugsversuche vermutet werden, aber nicht darunter
  • Nur Männer mit Umbrella Limit über null haben Betrug begangen
  • Wenn Männer mehr als 2 Zeugen benennen, ist Vorsicht geboten, da ein Betrug wahrscheinlich ist
  • Benennen Männer nur einen Zeugen, ist ein Betrug unwahrscheinlich
  • Kunden mit PhD und High Schoolabschluss sind generell vertrauenswürdig, außer sie kommen aus einem niedrigen Zip-Bereich
  • Schachspieler im Alter zwischen 30 und 40 betrügen Wahrscheinlich, ab 40 werden sie ehrlicher
  • Basketballer, denen etwas zwischen Mitternacht und 5:00am passiert, haben alle betrogen
  • Menschen, die gerne schlafen, betrügen, wenn sie vor 10:00am einen Schaden generiert haben
  • Hohe Verletzungskosten (für KFz geltend gemacht) bei Polospielern und Paintballern sind wahrscheinlich Betrug
  • Hohe Eigentumsschäden (für KFz geltend gemacht) bei Yachtbesitzern sind wahrscheinlich Betrug
  • Autodiebstähle mit Betrugsanzeige finden sich nur für den Zip-Bereich 470k bis 520k
  • Ein Unfall mit nur einem Auto ist wahrscheinlich ein Betrug, wenn es mehr als zwei Zeugen gibt
  • Gestohlene Autos, deren Versicherung in der ersten Monatshälfte greift, sind echt
  • Geringe Schäden und Totalschaden ohne Zeugen sind wahrscheinlich echt
  • Totalschäden mit mehr als zwei Zeugen sind wahrscheinlich betrug
  • Bagatellschäden sind wahrscheinlich Betrug, wenn die Versicherung zwischen Januar und Mai greift
  • Bagatellschäden sind wahrscheinlich echt, wenn die Versicherung in der ersten Monatshälfte greift
  • Wenn gegen Monatsende keine Behörden zur Dokumentation gerufen werden, leigt eine erhöhte Betrugswahrscheinlichkeit vor
  • Wird in höheren Zip-Bezirken keine Auskunft über property_damage gegeben, liegt wahrscheinlich Betrug vor
  • Bei Honda-Fahrern, die noch nicht lange Kunden sind (<150 Monate), ist vorsicht geboten
  • Autos von Accura mit Capital_loss von mehr als 50K Euro sind wahrscheinlich Betrug

An diesen Beispielen wird erneut deutlich, dass nicht jedes Ergebnis verwendbar ist. Auch wenn auf Basis der Daten viele Vorhersagen getroffen werden können, sind diese nicht zwingend zu verwenden. Als Beispiel für nicht zu verwendende Informationen kann die Erkenntnis "Basketballer, denen etwas zwischen Mitternacht und 5:00am passiert, haben alle betrogen" herangezogen werden, hingegen erscheint die Aussage "Wenn gegen Monatsende keine Behörden zur Dokumentation gerufen werden, liegt eine erhöhte Betrugswahrscheinlichkeit vor" durchaus sinnig.

nominal/ordinal skalierte Merkmale kodieren

Die meisten Machine Learning Algorithmen können mit nominalen Merkmalen nichts anfangen. Wir müssen also die Begrifflichkeiten auf sinvolle Weise in Zahlen überführen. Binäre Merkmale (Yes/No) werden im Folgenden als 1/0 kodiert. Merkmale mit mehr als 2 und weniger als 5 Ausprägungen werden One Hot Codiert, d.h. zu jeder Merkmalsausprägung wird ein neues Feature geschaffen und eine 1 gesetzt, wenn diese Ausprägung im Sample vorkommt und 0 sonst. Für Merkmale mit mehr als 4 Ausprägungen würde One Hot zu einer dünn besetzten Datenmatrix führen (sparse Data). Deshalb wird hier eine simple Form des Target Codings verwendet: Die Merkmalsausprägungen werden danach geordnet, wie häufig diese im Zusammenhang mit einem gemeldeten Betrug vorkamen. Anschließend wird diese geordnete Liste durchnummeriert und diese Nummerierung stellt die Kodierung dar. Je größer also die Zahl einer Target Codierten Variable, desto häufiger kommt diese Ausprägung zusammen mir einem Betrug vor.

In [94]:
BinaryColumns = []
OneHotColumns = []
TargetColumns = []
for col in Columns_object:
    if len(DF[col].value_counts()) == 2:
        BinaryColumns.append(col)
    elif 2 < len(DF[col].value_counts()) <= 4:
        OneHotColumns.append(col)
    else:
        TargetColumns.append(col)
    
enc = LabelEncoder()
for col in BinaryColumns:
    DF[col] = enc.fit_transform(DF[col])

enc = OneHotEncoder(handle_unknown='ignore')
for col in OneHotColumns:
    Vals     = DF[col].value_counts().index
    TransCol = enc.fit_transform(np.array(DF[col]).reshape(-1, 1))
    TransCol = TransCol.toarray()
    for id in range(len(Vals)):
        DF[col+'_'+Vals[id]] = TransCol[:,id]
        DF[col+'_'+Vals[id]] = DF[col+'_'+Vals[id]].astype(int)
        
for col in TargetColumns:
    Vals    = DF[col].value_counts().index
    AuxList = []
    for val in Vals:
        DF_fltr = DF[DF[col]==val]
        s       = np.sum(DF_fltr['fraud_reported'])
        AuxList.append([val,s])
    AuxDF  = pd.DataFrame(AuxList).sort_values(by=[1,0], ascending=True)
    Labels = list(AuxDF[0])
    AuxDict = {}
    for id in range(len(Labels)):
        AuxDict[Labels[id]] = id
    EncodedCol = [AuxDict[val2] for val2 in DF[col]]
    DF[col] = EncodedCol

Korrelationsanalyse

Anschließend wollen wir die Korrelationen untersuchen. Hierzu berechnen wir Kendall's Tau, das ein allgemeines Korrelationsmaß ohne Unterstellung eines funktionalen (z.B. linearen) Zusammenhangs auskommt. Dieser Wert ist ein Maß dafür, wie oft bei Wertepaaren $(x,y)\in X\times Y$ die Ausprägungen von Y die Rangfolge von X durchbrechen. Durch die veränderte Bedeutung der targetcodierten Variablen, ist besondere Vorsicht bei der Interpretation der jeweiligen Korrelationen geboten. Daher betrachten wir zuerst die targetcodierten Variablen.

In [16]:
Correlations = DF.corr(method='kendall')

msk = np.ones_like(Correlations)
id = 0
for col in Correlations.index:
    if col in TargetColumns:
        msk[id] = len(msk[id]) * [0]
    id += 1

fig, axarr = plt.subplots(nrows=1, figsize=(15,15))
sns.heatmap(Correlations, vmin=-1, vmax=1, cmap='coolwarm', mask=msk,
            linewidths=.5, ax=axarr)
plt.tight_layout()
plt.show()

fig, axarr = plt.subplots(nrows=1, figsize=(15,15))
sns.heatmap(Correlations, vmin=-1, vmax=1, cmap='coolwarm', linewidths=.5, ax=axarr)
plt.tight_layout()
plt.show()

Die erste Heatmap zeigt nur die target-codierten Merkmale, damit eine entsprechend veränderte Interpretation erfolgen kann. Die Korrelationen für diese Merkmale sind eher schwach und werden daher nicht in der Interpretation berücksichtigt.

Triviale Erkenntnisse:

  • Das Kundenalter korreliert positiv mit der Kundendauer
  • injury_claim, property_claim und vehicle_claim korrelieren positiv
  • je größer injury_claim, property_claim und vehicle_claim, desto größer total_claim_amount
  • je mehr Autos in einem unfall involviert sind, desto häufiger handelt es sich um eine Multi-Vehicle-Collision
  • je mehr Autos in einem unfall involviert sind, desto unwahrscheinlicher ist es, dass kein Property Damage angegeben wird
  • bei einer mulit-vehicle-collision sind die claim_Amounts höher
  • bei trivialem Schaden sind die claims kleiner
  • bei Multivehicle collision ist trivial damage unwahrscheinlich
  • bei single vehicle colision ist Rearcollision und trivial damage wahrscheinlich
  • rear collision ist bei parked car sehr wahrscheinlich

Latente Informationen:

  • bei einem Autodiebstahl sind alle Claims höher, auch injury claim und property claim (ist losgelöst von vehicle claim)
  • Handelt es sich um eine RearCollision, sind die Claims niedriger
  • Handelt es sich um eine Side oder Front Collision, sind die Claims höher
  • Handelt es sich um einen Unfall im geparkten zustand, sind die Claims kleiner (auch auto claim)
  • Totalschaden korreliert nicht mit den Claims
  • Fraud korreliert mit den Insured hobbies (target codiert), d.h. Fraud-Bereitschaft kann in der Tat an Hobbies abgelesen werden
  • Fraud korreliert schwach mit den Claims, d.h. je kleiner die Claims, desto geringer die Fraud-W'keit
  • Fraud korreliert mit minor incident severity, d.h. zusammen mit dem vorherigen Punkt: Wenn bei geringer Unfallschwere dennoch hohe claims gefordert werden, ist das ein guter Indikator für einen Betrug
  • Fraud korreliert schwach mit Auto-Marke und -Modell (Target Codiert), d.h. Fraud-Bereitschaft kann (mit Unsicherheit) an diesen Features geschätzt werden
  • Bei Totalschaden ist ein Betrug in der Regel unwahrscheinlich
  • Fraud korreliert mit Straße des Unfalls (Target Codiert), d.h. diese ist ein guter Indikator für Fraud
  • triviale Schäden gibt es häufig bei geparkten Autos, single car incidents und rear collision

Zusammenfassend ergeben sich aus diesem Abschnitt viele Zusammenhänge, aus denen strategische Entscheidungen für angepasste Tarife oder neue Versicherungsprodukte entstehen können. Bei diesen und anderen Erkenntnissen ist jedoch stets zwischen Koinzidenz und Kausalität zu unterscheiden. Dies haben wir am Beispiel der Baksetballspieler bzw. durch das Nichthinzuziehen von Behörden verdeutlicht. Im Folgenden wollen wir auf Basis von Machine Learning latente Muster im Datensatz erkennen, die auf Betrugsfälle hinweisen und die jeweiligen Fälle automatisch als Prüffälle markieren (z.B. für eine tiefergehende menschliche Überprüfung).

3. Feature Importance und Betrugsvorhersage (Prediction)

Im Folgenden untersuchen wir die Fraud-Detection Performance von gängigen Classifiern in der Implementierung von Scikit. Dabei fokussieren wir uns auf:

  • Logistische Regression optimiert eine Sigmoid-Funktion für den Zusammenhang dichotomer und ordinaler Merkmale und liefert durch die stetige Fortsetzung zwischen 0 und 1 eine Wahrscheinlichkeitsinterpretation. [https://de.wikipedia.org/wiki/Logistische_Regression]
  • Random Forest entstehen durch das anlernen von mehreren Decision Trees auf verschiedenen Subsamples des Trainingsdatensatzes. Jeder Decision Tree kann dabei als eine Abfolge regelbasierter Entscheidungen verstanden werden, wobei die Induktion der Entscheidungsregeln sukzessive anhand eines Splittings von wichtigen Features durchgeführt wird, sodass dieser Split die Gini Impurity minimiert (= W'keit für Fehlklassification anhand der Verteilung die aus diesem Split entsteht). [https://en.wikipedia.org/wiki/Decision_tree_learning]
  • Naive Bayes beschreibt Algorithmen, die auf der Anwendung des Bayes Theorems beruhen und die naive Annahme vonn iid-Merkmalen voraussetzt. Aus den auf dem Training Set errechneten bedingten Wahrscheinlichkeiten $P(target|FeatureVector)$ kann anschließend durch maximum a-posteriori Schätzung ein Schätzer für die Klassenzugehörigkeit einer ungesehenen Zielvariable berechnet werden. Wir verwenden die zusätzliche Annahme, dass die Merkmale normalverteilt vorliegen. [https://scikit-learn.org/stable/modules/naive_bayes.html#gaussian-naive-bayes]
  • SVM erzeugt eine Menge von Hyperebenen in einem Hochdimensionalen Vektorraum durch Minimierung des Abstandes zu den verschiedenen Clustern. [https://en.wikipedia.org/wiki/Support_vector_machine]

An Feature Importance verwenden wir:

  • PCA ermittelt ein neues Koordinatensystem mit einer niedrigeren Dimension als der ursprüngliche Feature-Raum. Dabei werden die neuen Koordinatenachsen entlang der größten Varianzen innerhalb des Datensatzes gelegt und ein Koordinatenwechsel durch orthogonale Projektion durchgeführt. Die neuen Koordinaten stellen dann eine niederdimensionale Repräsentation des Datensatzes bei minimalem Informationsverlust dar. [https://de.wikipedia.org/wiki/Hauptkomponentenanalyse]
  • Mutual k-Best wählt die $k$ Features mit der höchsten Transinformation zum Merkmal fraud_reported aus. Transinformation ist hierbei ein Maß für die gewonnene Information einer Variable (z.B. fraud_reported), wenn eine andere Variable (z.B. total_claim_amount) beobachtet wird. [https://en.wikipedia.org/wiki/Mutual_information]
  • Permutation Importance ist eine Classifier-abhängige Methode zur Schätzung der wichtigsten Features. Dabei wird ein Classifier auf einem Teildatensatz angelernt und die Referenz-Accuracy berechnet. Anschließend wird jedes Feature nach und nach permutiert, um eventuelle Abhängigkeiten zur Target-Variable zu zerstören. Anschließend wird die Accuracy erneut gemessen. So können die wichtigsten Features bestimmt werden. Wir verwenden im Folgenden die 10 wichtigsten Features. [https://scikit-learn.org/stable/modules/permutation_importance.html]

Die Classifier werden zunächst in einem Dictionary initialisiert und der Datensatz zur Verbesserung der Performance normalisiert:

In [95]:
CLFdict = {
'Logistic'     : LogisticRegression(random_state=41856),
'RandomForest' : RandomForestClassifier(bootstrap=True, random_state=41856),
'NaiveBayes'   : GaussianNB(),
'SVM'          : SVC(random_state=41856)
}

scaler = StandardScaler()
X  = DF.drop(columns=OneHotColumns).drop(columns=['fraud_reported'])
_X = pd.DataFrame(scaler.fit_transform(X), columns=X.columns)
Y  = DF['fraud_reported']

Die oben erwähnten Modelle werden im Folgenden getestet. Dazu verwenden wir einen randomisierten 70/30-Split des Datensatzes in Training und Testing. Wir verwenden kein Dev-Set, da wir lediglich einen Vergleich der Ausgangsbasis anstellen und an dieser Stelle keine Optimierung vornehmen. Wir trainieren auf dem Train-Set und testen sowohl auf Train- wie auch auf Test-Set, um ein Gefühl für das Overfitting zu bekommen. Als Metriken wählen wir Precision und Recall, später auch den F1-Score. Precision beschreibt, mit welcher Wahrscheinlichkeit ein gefundener Betrug wirklich ein Betrug ist. Recall gibt an, welcher relative Anteil an Betrugsfällen tatsächlich gefunden wurde. der F1-Score ist ein gewichtetes Mittel aus Precision und Recall [https://en.wikipedia.org/wiki/Precision_and_recall]. Da Betrug in unserem Datensatz ein dichotomes Merkmal ist, gibt zu jeder Metrik theoretisch zwei Werte (Metrik für Klasse 0, d.h. kein Betrug und für Klasse 1, d.h. Betrug). Da es uns darauf ankommt, Betrug zu erkennen, verwenden wir nur die Metrik-Werte für die Klasse 1. Dies schlägt sich im Code durch average='binary' wieder. Eine Besonderheit unserer Auswertung, die leider nicht weit Verbreitet ist, ist die Wiederholung der obigen Messung. Die Performance von Machine Learning hängt maßgeblich von der Wahl des Trainingsdatensatzes ab, sodass sich bei (un)günstiger Randomisierung bessere oder schlechtere Werte ergeben können. Um die Gesamtperformance besser bewerten und den Einfluss des Trainingsdatensatzes vollständig abschätzen zu können, werden die oben beschriebenen Analysen 250 mal wiederholt, sodass sich für Precision und Recall (bzw. F1-Score) Verteilungen ergeben. Um bei erneutem Durchlauf gleiche Ergebnisse zu garantieren, setzen wir einen fixen Seed für den Zufallsgenerator. Für die folgende Analyse kodieren wir den Datensatz vollständig vor dem Splitting, damit wir die bestmöglichen Ergebnisse erhalten. Bei der finalen Bewertung wird die Kodierung nach dem Splititng erfolgen, um der Realität Rechnung zu tragen, in der der neue Datensatz unbekannt ist und durch fix angelernte Pipelines bearbeitet wird.

In [96]:
def TestMyModels(Features, Labels, CLFdict, FeatureMethod, reps=25, verbose=True):
    np.random.seed(41856)
    Seeds = np.random.randint(0,10**6, size=reps)
    Results = []
    if verbose == True:
        for rep in range(reps):
            print('Repetition:', rep)
            X_train, X_test, Y_train, Y_test = train_test_split(Features, Labels, test_size=.3, 
                                                                shuffle=True, random_state=Seeds[rep])
            for classifier_string in CLFdict:
                print('Classifier: %s -- Repetition: %s'%(classifier_string,rep))
                clf = CLFdict[classifier_string].fit(X_train, Y_train)
                ### Prediction on Training Set
                Predictions = clf.predict(X_train)
                p,r,f1,s = precision_recall_fscore_support(Y_train, Predictions, average='binary')
                Results.append([classifier_string, FeatureMethod, 'train', p, r, f1])
                print('\t Train Set F1 Score: %.2f'%f1)
                ### Prediction on Test Set
                Predictions = clf.predict(X_test)
                p,r,f1,s = precision_recall_fscore_support(Y_test, Predictions, average='binary')
                Results.append([classifier_string, FeatureMethod, 'test', p, r, f1])
                print('\t Test Set F1 Score: %.2f'%f1)
    else:
        for rep in range(reps):
            X_train, X_test, Y_train, Y_test = train_test_split(Features, Labels, 
                                                                test_size=.3, shuffle=True)
            for classifier_string in CLFdict:
                clf = CLFdict[classifier_string].fit(X_train, Y_train)
                ### Prediction on Training Set
                Predictions = clf.predict(X_train)
                p,r,f1,s = precision_recall_fscore_support(Y_train, Predictions, average='binary', warn_for=tuple())
                Results.append([classifier_string, FeatureMethod, 'train', p, r, f1])
                ### Prediction on Test Set
                Predictions = clf.predict(X_test)
                p,r,f1,s = precision_recall_fscore_support(Y_test, Predictions, average='binary', warn_for=tuple())
                Results.append([classifier_string, FeatureMethod, 'test', p, r, f1])
    ### Building the Data Frame of Results
    ResArray = np.array(Results).reshape(-1,6)
    ResDF    = pd.DataFrame(ResArray, columns=['Classifier', 'FeatureSelection', 'EvalSet', 'Precision', 'Recall', 'F1score'])
    ResDF['Classifier']       = ResDF['Classifier'].astype(str)
    ResDF['FeatureSelection'] = ResDF['FeatureSelection'].astype(str)
    ResDF['Precision']        = ResDF['Precision'].astype(float)
    ResDF['Recall']           = ResDF['Recall'].astype(float)
    ResDF['F1score']          = ResDF['F1score'].astype(float)
    return ResDF

Wir wählen für die Hauptkomponentenanalyse (PCA) so viele Variablen aus, dass 90% der Gesamtinformation (Varianz) des Datensatzes erhalten bleibt und projezieren den Datensatz anschließend in den reduzierten Vektorraum. Dadurch erhalten wir letztlich eine Reduktion der statistischen Merkmale, die ggf. zu einer Steigerung der Performance führen wird. Es ist zu beachten, dass diese neu generierten Merkmale in der Regel keine semantische Information mehr besitzen.

In [97]:
pca   = PCA(.90) ; pca.fit(_X) ; npc = len(pca.explained_variance_ratio_)
print("Reduction: From %s to %s dimensions.\n"%(len(_X.columns),npc))
print("Explained Variance:\n", pca.explained_variance_ratio_)
X_pca = pca.transform(_X)

ResDF = TestMyModels(X_pca, Y, CLFdict, 'pca', reps=100, verbose=False)
Reduction: From 58 to 37 dimensions.

Explained Variance:
 [0.10854186 0.04564576 0.03815715 0.03389547 0.03277678 0.0312266
 0.02933039 0.02896455 0.02724055 0.02651668 0.02615768 0.02523479
 0.02455643 0.02439578 0.02338701 0.02260311 0.02133421 0.02021376
 0.0199643  0.01956088 0.01915984 0.01896218 0.01858238 0.0179439
 0.01749571 0.01745507 0.01664793 0.01649824 0.01628924 0.01601722
 0.01564392 0.01536802 0.01527112 0.01489398 0.01461979 0.01456604
 0.01419869]

Für den k-Best Algorithmus verwenden wir $k=1,\ldots,10$ und erzeugen somit 10 Datensätze, die mit den verschiedenen Classifiern getestet werden.

In [98]:
for K in range(1,11):
    selector = SelectKBest(mutual_info_classif, k=K).fit(X, Y)
    X_new    = selector.transform(X)
    mask     = selector.get_support()
    SelectedFeatures = np.array(X.columns)
    SelectedFeatures = SelectedFeatures[mask]
    X_kbest  = pd.DataFrame(X_new, columns=SelectedFeatures)

    FeaturetMethod = str(K)+'best'
    
    _ResDF = TestMyModels(X_kbest, Y, CLFdict, FeaturetMethod, reps=100, verbose=False)
    ResDF  = pd.concat([ResDF,_ResDF], ignore_index=True)

Da die Permutation Feature Importance vom Classifier abhängig ist, müssen wir die oben definierte Funktion hier wieder aufbrechen. Für jeden Classifier lernen wir diesen zunächst auf dem Trainingsdatensatz an und berechnen dann die zehn wichtigsten Features. Anschließend wird jeder Classifier auf 250 verschiedenen 70/30-Train-Test-Splits auf dem reduzierten Datensatz getestet.

In [99]:
Results  = []
for classifier_string in CLFdict:
    clf  = CLFdict[classifier_string].fit(_X, Y)
    perm = permutation_importance(clf, _X, Y, n_repeats=30, scoring='f1')
    ImportanceDF = pd.DataFrame(perm.importances_mean,  
                                columns=['FeatureImportance'], index=_X.columns)
    ImportanceDF = ImportanceDF.sort_values(by='FeatureImportance', ascending=False)
    Top5 = list(ImportanceDF.iloc[:10].index)
    
    np.random.seed(41856); Seeds = np.random.randint(0,10**6, size=100)
    for rep in range(100):
        X_train, X_test, Y_train, Y_test = train_test_split(_X[Top5], Y, test_size=.3, 
                                                            shuffle=True, random_state=Seeds[rep])
        clf.fit(X_train, Y_train)
        ### Prediction on Training Set
        Predictions = clf.predict(X_train)
        p,r,f1,s    = precision_recall_fscore_support(Y_train, Predictions, average='binary')
        Results.append([classifier_string, 'Top10Permut', 'train', p, r, f1])
        ### Prediction on Test Set
        Predictions = clf.predict(X_test)
        p,r,f1,s    = precision_recall_fscore_support(Y_test, Predictions, average='binary')
        Results.append([classifier_string, 'Top10Permut', 'test', p, r, f1])
        
ResArray = np.array(Results).reshape(-1,6)
_ResDF   = pd.DataFrame(ResArray, columns=['Classifier', 'FeatureSelection', 'EvalSet', 'Precision', 'Recall', 'F1score'])
_ResDF['Classifier'] = _ResDF['Classifier'].astype(str)
_ResDF['FeatureSelection'] = _ResDF['FeatureSelection'].astype(str)
_ResDF['Precision']  = _ResDF['Precision'].astype(float)
_ResDF['Recall']     = _ResDF['Recall'].astype(float)
_ResDF['F1score']    = _ResDF['F1score'].astype(float)       
ResDF = pd.concat([ResDF,_ResDF] , ignore_index=True) 

Wir entscheiden uns bei den Vergleichen für Boxplots, da diese die statistische Natur der Quality Measures darstellen und gleichzeitig übersichtlicher sind, als z.B. die Betrachtung aller Kerndichteschätzungen in einem Bild. Wir betrachten für jeden einzelnen Classifier Precision und Recall unterteilt nach Datensatz mit einem zusätzlichen Farbsplitting für Train- und Test-Set.

In [100]:
for clst in CLFdict:
    AuxDF = ResDF[(ResDF.Classifier == clst)]
    print('-------',clst,'-------')
    fig,axarr = plt.subplots(nrows=1, ncols=2, sharey=True, figsize=(13,4))
    sns.boxplot(x='FeatureSelection', y="Precision", hue='EvalSet', data=AuxDF, ax=axarr[0])
    sns.boxplot(x='FeatureSelection', y="Recall",    hue='EvalSet', data=AuxDF, ax=axarr[1])
    axarr[0].tick_params(labelrotation=45)
    axarr[1].tick_params(labelrotation=45)
    plt.tight_layout()
    plt.show()          
    print('\n\n')
------- Logistic -------


------- RandomForest -------


------- NaiveBayes -------


------- SVM -------


  • Logistische Regression funktioniert gut mit PCA (leichtes Overfitting) und Top10 Permutation. Ab 6best führen weitere Features nur zur Verschlechterung. Zwischen Precision und Recall gibt es keine Auffälligkeiten. Insgesamt kratzt dieser Classifier an der 0.9-Marke (Verteilungen mit hoher Varianz ausgeschlossen).
  • Random Forest weist fast immer Overfitting auf. Die beste Precision ist mit der PCA zu erreichen, geht jedoch mit dem schlechtesten Recall einher. Da es (im ersten Schritt) nicht darum geht, Betrugsfälle endgültig und ohne menschliche Prüfung zu erkennen und abzuweisen, sondern vielmehr darum Prüffälle zu finden, die näher untersucht werden müssen, ist Recall insgesamt sogar etwas wichtiger als Precision. Für K-Best ist eine Qualitätssteigerung bis K=4 zu erkennen, danach bleibt die Qualität konstant. Insgesamt werden Werte von 0.9 öfters überschritten.
  • NaiveBayes besitzt nur leichtes Overfitting bei PCA und liefert die besten Ergebnisse bei 4best und 5best. Insgesamt bleiben die Werte aber bei der 0.85-Grenze. Der Classifier ist daher eher ungeeignet.
  • SVM liefert zwar sehr oft gute Ergebnisse für die Precision aber immer einhergehend mit einem schlechten Recall. Für Top10Permut und pca existiert sehr starkes Overfitting. Insgesamt ist dieser Classifier ungeeignet.
In [101]:
fig, axs = plt.subplots(nrows=1, figsize=(15,7))
sns.boxplot(y="F1score", x='Classifier', hue='FeatureSelection', palette='Paired',
            data=ResDF[ResDF.EvalSet == 'test'], ax=axs)
plt.tight_layout()
plt.show()

Es gibt große Überschneidungen, der Verteilungen, sodass ein 'besser' oder 'schlechter' immer mit einer gewissen Fehlerwahrscheinlichkeit verbunden ist. Insgesamt sind aber die meisten Scores für den Random Forrest ein wenig höher als die der anderen Classifier. Im Vergleich bietet der Random Forest von allen Classifiern von Natur aus die besten Startvoraussetzungen. Die besten Werte werden für die Feature Selection 6best bis 10best erreicht. Da die Qualität ab 5best nicht mehr weiter zunimmt, stellt k=6 aus Gründen der Komplexitätsreduktion die beste Wahl dar. Overfitting ist beim Random Forest zwar gegeben, aber nicht so ausgeprägt, dass man es nicht in den Griff kriegen könnte, insbesondere indem die Tiefe der Zweige limitiert wird. Dieser Classifier bietet, wie schon gesagt, die besten Startbedingungen für eine Optimierung.

Optimierung des besten Classifiers

Leider ist der Random Forest aber auch ein Classifier, der nur wenige Hyperparameter für das Tuning bietet. Beispielsweise kann die Anzahl der verwendeten Decision Trees zu besseren und robusteren Entscheidungen führen. Da wir jedoch ein dichotomes Merkmal vorhersagen und die Standardeinstellung bereits 100 Decision Trees vorsieht, ist eine Verbesserung der Scores durch zusätzliche Decision Trees eher unwahrscheinlich. Zusätzlich bietet ein Random Forest noch die maximale Tiefe, sowie die Mindestzahl an Samples pro Zweig oder Split als mögliche Parameter. All diese Parameter beeinflussen die Verzweigung und verhindern, dass jedes Sample im Trainings-Datensatz einen eigenen Ast bekommt. So ist davon auszugehen, dass eine Optimierung lediglich das Overfitting in den Griff kriegt.

Bemerkungen:

  • Wir haben bislang den vollständig kodierten Datensatz verwendet und die Classifier mit einem 70/30-Split untersucht. Dadurch haben wir den besten Algorithmus unter optimalen Bedingungen gefunden. Dies war zulässig, weil wir nur verglichen und nicht optimiert haben.
  • Beim Optimieren kann es jedoch auch zu einem Overfitting auf dem Test-Set kommen, sodass die berechneten Scores nur wenig Aussagekraft auf die Performance ungesehener Daten besitzt. Deshalb teilen wir den Datensatz in ein Dev-Set und ein Test-Set mittels 75/25-Split. Auf dem Test-Set wird dann mittels 3-Fold-Splitting optimiert. Dies ergibt dann abermals 50% für das Training und 25% für das Testing.
  • In der Realität wird der Test-Datensatz aber unabhängig vom Trainingsdatensatz kodiert, da dieser in der Regel erst nach der Erstellung des Classifiers entsteht. Dies wird im Folgenden berücksichtigt werden.
  • Die Herausforderung besteht darin, dass der Test-Datensatz neue Label enthält, die durch die Encoder nicht berücksichtigt werden können. Da die SciKit-Encoder kein akzeptables oder konfigurierbares UnknownValue-Handling haben, müssen diese eigens neu programmiert werden (auch das muss ein guter Data Scientist können - dafür braucht man Leute, die wissen, was hinter dem Deckmantel einer Funktion eigentlich passiert).
  • Natürlich müssen wir den Datensatz hierfür noch einmal neu laden und augmentieren. Glücklicherweise haben wir das zuvor als Funktion implementiert!
In [5]:
DF = pd.read_csv(".\datasets_45152_82501_insurance_claims.csv")
DF = cleanup(DF)
DF = augment(DF)
In [6]:
class Encoder:
    def __init__(self):
        pd.options.mode.chained_assignment = None
        self.EncoderDict   = {}
        self.BinaryColumns = []
        self.OneHotColumns = []
        self.TargetColumns = []
        self.ignore_count  = 0
    
    def reset(self):
        self.EncoderDict   = {}
        self.BinaryColumns = []
        self.OneHotColumns = []
        self.TargetColumns = []
        self.ignore_count  = 0

    
    def determine_encoding_types(self, DF_0):
        DF = DF_0.copy(deep=True)
        for col in DF.select_dtypes(include=['object']).columns:
            if len(DF[col].value_counts()) == 2:
                self.BinaryColumns.append(col)
            elif 2 < len(DF[col].value_counts()) <= 4:
                self.OneHotColumns.append(col)
            else:
                self.TargetColumns.append(col)
    
    def fit(self, DF_0):
        self.reset()
        DF = DF_0.copy(deep=True)
        self.determine_encoding_types(DF)
        for col in self.BinaryColumns:
            Vals = sorted(list(set(DF[col])))
            BinaryDict = {Vals[0]:0 , Vals[1]:1}
            self.EncoderDict[col] = BinaryDict
        for col in self.OneHotColumns:
            AuxDF = DF[col]
            Vals  = AuxDF.value_counts().index
            self.EncoderDict[col] = Vals
        for col in self.TargetColumns:
            Vals    = DF[col].value_counts().index
            AuxList = [] 
            for val in Vals:
                DF_fltr = DF[DF[col]==val]
                s       = np.sum(DF_fltr['fraud_reported'].replace({'Y':1,'N':0}))
                AuxList.append([val,s])
            AuxDF  = pd.DataFrame(AuxList).sort_values(by=[1,0], ascending=True)
            Labels = list(AuxDF[0]) ; TargetDict = {}
            for id in range(len(Labels)):
                TargetDict[Labels[id]] = id
            self.EncoderDict[col] = TargetDict
        
    def transform(self, DF_0):
        DF = DF_0.copy(deep=True)
        self.ignore_count = 0
        ###
        for col in self.BinaryColumns:
            BinaryDict = self.EncoderDict[col]
            for val in BinaryDict:
                mask = (DF[col]==val)
                DF[col][mask] = BinaryDict[val]
            mask = (DF[col]!=0) & (DF[col]!=1)
            DF[col][mask] = -99 #np.nan
            self.ignore_count += np.sum(mask)
            DF[col] = DF[col].astype(int)
        ###
        for col in self.OneHotColumns:
            AuxDF = DF[col]
            Vals_fit = self.EncoderDict[col]
            Vals     = AuxDF.value_counts().index
            for id in range(len(Vals)):
                if Vals[id] in Vals_fit:
                    DF[col+'_'+Vals[id]] = 1 * np.array(AuxDF == Vals[id])
                    DF[col+'_'+Vals[id]] = DF[col+'_'+Vals[id]].astype(int)
                else:
                    self.ignore_count += np.sum(AuxDF == Vals[id])
        DF = DF.drop(columns=self.OneHotColumns)
        ###
        for col in self.TargetColumns:
            TargetDict = self.EncoderDict[col]
            msk = ~DF[col].isin(TargetDict.keys())
            DF[col][msk] = -99 #np.nan
            DF[col] = DF[col].replace(TargetDict)
            self.ignore_count += np.sum(mask)
        return DF
    
    def fit_transform(self, DF_0):
        self.fit(DF_0)
        DF = self.transform(DF_0)
        return DF

Wie gravierend ist eigentlich das Problem, dass neue Labels im Test-Datensatz vorkommen? Diese können leider nicht nachträglich kodiert werden. Bei Binärcodierung (1,0), würde ein zusätzliches Label noch eine weitere Zahl erfordern, worauf der Classifier nicht angelernt wurde (weniger problematisch). Bei OneHot-Encoding würde eine neue Feature-Spalte geschaffen werden, sodass die Dimension des Datensatzes nicht mit den Dimensionen des angelernten Classifiers übereinstimmt (sehr problematisch). Bei TargetCoding ist die Ordnungsrelation des neuen Labels zu den anderen vollkommen unklar und kreirte Korrelationen können zerstört werden. Wir müssen also wissen, wie oft dieser Umstand vorkommt.

In [27]:
X = np.linspace(.01,.1, num=50)
Y = []

enc = Encoder()
for trainsize in X:
    Unknowns = []
    for rep in range(50):
        A, B = train_test_split(DF, train_size=trainsize, shuffle=True, random_state=41856)  
        a,b = B.shape ; N = a*b
        enc.fit(A) ; B_ = enc.transform(B)
        H = enc.ignore_count ; h = np.round(H/N, decimals=4)
        Unknowns.append(h)
    Y.append(np.mean(Unknowns))

sns.lineplot(x=100*np.array(X), y=100*np.array(Y))
plt.xlabel('Relative Training Set Size [in %]')
plt.ylabel('Relative Frequency of Unknown Values [in %]')
plt.tight_layout()
plt.show()

Das Problem ist existent für eine Trainingsgröße von weniger als 3% (etwa 30 Samples). Wenn wir den Datensatz in 50/25/25 splitten, tritt das befürchtete Problem gar nicht auf. Im Folgenden verwenden wir 50% des Datensatzes als Training-Set und 25% als Test-Set. Hier wird der Random Forest optimiert. Seine Gesamtperformance wird dann anschließend auf die restlichen 25% des Datensatzes überprüft. Dies geschieht mehrmals für unterschiedliche Zusammenstellungen dieser Datensätze. Die Kodierung ist also für unsere weitere Analysen bedenkenlos nutzbar.

Zur Vereinfachung bauen wir aus Kodierung und Normalisierung eine Data-Pipeline:

In [7]:
def pipeline(Train, Test, feature_reduction=[]):
    enc     = Encoder()
    scaler  = StandardScaler()
    # Kodieren
    Train_enc   = enc.fit_transform(Train)
    Test_enc    = enc.transform(Test)
    # Labels 
    Y_train = Train_enc['fraud_reported']
    Y_test  = Test_enc['fraud_reported']
    # Features
    X_train = Train_enc.drop(columns=['fraud_reported'])
    X_test  = Test_enc.drop(columns=['fraud_reported'])
    # Reduzierung
    if len(feature_reduction) > 0:
        X_train = X_train[feature_reduction]
        X_test  = X_test[feature_reduction]
    # Skalierung
    X_train = pd.DataFrame(scaler.fit_transform(X_train), columns=X_train.columns)
    X_test  = pd.DataFrame(scaler.transform(X_test), columns=X_test.columns)
    return X_train, Y_train, X_test, Y_test

Für die folgende Optimierung verwenden wir einen randomisierten Grid-Search mit einer k-fold Cross Validation, um Train-Dev-Test-Splitting zu realisieren. Dabei wird für jeden zu variierenden Hyperparameter eine Liste mit möglichen Werten vorgegeben, sodass das Kreuzprodukt ein Gitter von möglichen Parameterkonstellationen erzeugt. Aus diesem Gitter werden zufällig $n$ Punkte ausgewählt und getestet. Der 75%-Split wird dann in drei gleichgroße Segmente (3-fold cross validation) aufgeteilt, von denen je zwei Teile (50%) zum anlernen und der übrige Teil (25%) zur Evaluation genutzt wird. Entsprechend ergeben sich für jede Parameterkombination drei Durchläufe von denen der resultierende Performance-Score durch mittelwertbildung abgeleitet wird. Auf diese Weise kann ein randomisiertes Train-Dev-Verfahren implementiert werden. Um der Zufälligkeit des Test-Sets Rechnung zu Tragen, wird der initiale 75/25-Split ebenfalls 100 Mal wiederholt. So erhalten wir ein vollständig randomisiertes Train-Dev-Test-Set Framework mit 50/25/25-Splitting. Für die folgende Analyse verwenden wir ein Parametergitter mit 2500 Punkten von denen in jedem der 100 Durchläufe genau 500 Kombinationen getestet werden. Später kann das Cross-validierte Modell nochmals auf Train/Test-Splits getestet werden. Wir folgen damit dem Vorgehen, wie es hier empfohlen wird: [https://scikit-learn.org/stable/modules/cross_validation.html]

Achtung: Die Ausführung des folgenden Codes benötigt zwischen 12 und 13 Stunden!

In [ ]:
random_grid = {'n_estimators':      np.linspace(10, 1000, num=10, dtype=int),
               'max_depth':         np.linspace(5, 50,    num=10, dtype=int),
               'min_samples_split': np.linspace(2, 10,    num=5,  dtype=int),
               'min_samples_leaf':  np.linspace(1, 10,    num=5,  dtype=int)}

selector  = SelectKBest(mutual_info_classif, k=6)
clf       = RandomForestClassifier(bootstrap=True, class_weight='balanced_subsample', random_state=41856)
optimizer = RandomizedSearchCV(estimator=clf, param_distributions=random_grid, scoring='f1',
                               n_iter=500, cv=3, n_jobs = -1, random_state=41856, verbose=True)

Cols                 = list(random_grid.keys())+['EvalSet','Precision','Recall','F1score']
BestHyPa_DF          = pd.DataFrame(columns=Cols)  
SelectedFeaturesList = []  
 
np.random.seed(41856); Seeds = np.random.randint(0,10**6, size=100)
for rep in range(100):
    print('Repetition', rep)
    ### Datensätze vorbereiten
    Dev, Test  = train_test_split(DF, test_size=.25, shuffle=True, random_state=Seeds[rep])
    X_dev, Y_dev, X_test, Y_test = pipeline(Dev, Test)
        
    X_new = selector.fit_transform(X_dev, Y_dev)
    mask  = selector.get_support()
    SelectedFeatures = np.array(X_dev.columns)[mask]
    X_dev_reduced = pd.DataFrame(X_new, columns=SelectedFeatures)
    
    X_new = selector.transform(X_test)
    X_test_reduced = pd.DataFrame(X_new, columns=SelectedFeatures)
    SelectedFeaturesList.append(SelectedFeatures)
    
    ### Optimierung des Classifiers
    optimizer.fit(X_dev_reduced, Y_dev) 
    BestDict = optimizer.best_params_
    
    clf.set_params(**BestDict)
    clf.fit(X=X_dev_reduced, y=Y_dev)
    
    ### Speichern der Performance Results
    AuxDF = pd.DataFrame(BestDict, index=[0])
    Predictions = clf.predict(X_dev_reduced)
    p,r,f1,s = precision_recall_fscore_support(Y_dev, Predictions, average='binary')
    AuxDF['EvalSet']='dev'; AuxDF['Precision']=p; AuxDF['Recall']=r; AuxDF['F1score']=f1
    BestHyPa_DF  = pd.concat([BestHyPa_DF,AuxDF] , ignore_index=True)
    
    AuxDF = pd.DataFrame(BestDict, index=[0])
    Predictions = clf.predict(X_test_reduced)
    p,r,f1,s = precision_recall_fscore_support(Y_test, Predictions, average='binary')
    AuxDF['EvalSet']='test'; AuxDF['Precision']=p; AuxDF['Recall']=r; AuxDF['F1score']=f1
    BestHyPa_DF  = pd.concat([BestHyPa_DF,AuxDF] , ignore_index=True)

BestHyPa_DF[list(random_grid.keys())] = BestHyPa_DF[list(random_grid.keys())].astype('int64')

Als Ergebnis erhalten wir eine Tabelle (genauer: Data Frame), die jeder zufällig ausgewählten Parameterkombination die Metriken Precision, Recall und F1 auf dem Dev- und Testset gegenüberstellt. Weiterhin erhalten wir eine Liste mit den ausgewählten Merkmalen, die durch k-Best in den Iterationen ausgewählt wurden. Zuerst betrachten wir, welche Features am häufigsten ausgewählt wurden (Ergebnisse unterscheiden sich aufgrund des randomisierten Train-Dev-Test-Splits):

In [35]:
print('most frequent 6-Best features:\n\n', pd.DataFrame(np.array(SelectedFeaturesList).flatten())[0].value_counts().head(6))

fig, axs = plt.subplots(nrows=1, figsize=(15,7))
pd.DataFrame(np.array(SelectedFeaturesList).flatten())[0].value_counts().plot(kind='bar', ax=axs)
plt.title('Top 4 Features for 4Best')
plt.ylabel('Absolute Count')
plt.tight_layout()
plt.show()
most frequent 6-Best features:

 incident_severity_Major Damage    100
incident_location_street           99
insured_hobbies                    96
incident_severity_Minor Damage     49
property_claim                     37
total_claim_amount                 20
Name: 0, dtype: int64

Erkenntnis: Die vier am häufigsten als 4best erkannten Features sind:

  • incident_severity_Major Damage
  • incident_location_street
  • insured_hobbies
  • incident_severity_Minor Damage
  • property_claim
  • total_claim_amount

Diese werden auch für unseren finalen Classifier verwendet werden.

Für die Auswahl der optimalen Parameter für unseren Random Forest gibt es verschiedene Vorgehensweisen:

  • Modalwert
  • Mittelwert
  • Single Best Score

Betrachten wir zuerst die ersten beiden Vorgehensweisen:

In [32]:
fig, axs = plt.subplots(ncols=4, figsize=(15,4))
for id in range(4):
    ParameterVals = BestHyPa_DF.iloc[:,id]
    sns.distplot(ParameterVals, kde=False, ax=axs[id])
    meanval = np.mean(ParameterVals)
    modeval = ParameterVals.mode().iloc[0]
    axs[id].set_title('Mean: %s // Mode: %s'%(meanval,modeval))
plt.tight_layout()
plt.show()
  • Laut Modalwert wären die Parameter (10,5,10,3).
  • Laut Mittelwert wären die Parameter (449,18,7,4).

Betrachten wir nun die letzte Vorgehensweise (Single Best Score):

In [36]:
BestHyPa_DF[BestHyPa_DF.EvalSet == 'test'].sort_values(by='F1score', ascending=False)
Out[36]:
n_estimators max_depth min_samples_split min_samples_leaf EvalSet Precision Recall F1score
171 670 5 6 1 test 0.866408 0.848 0.854232
127 670 50 6 1 test 0.860071 0.836 0.843843
193 10 30 10 3 test 0.851708 0.836 0.841390
191 670 5 6 1 test 0.840000 0.840 0.840000
137 890 10 2 3 test 0.851367 0.828 0.835592
... ... ... ... ... ... ... ... ...
115 670 5 6 10 test 0.605582 0.612 0.608726
17 120 5 2 3 test 0.600211 0.608 0.603983
163 560 5 8 1 test 0.577104 0.592 0.584071
117 230 5 4 3 test 0.570163 0.600 0.583159
63 10 10 6 3 test 0.540732 0.568 0.552503

100 rows × 8 columns

Ausgehend vom besten F1-Score sind die Parameter (640,5,6,1) am erfolgsversprechendsten.

Vergleich der optimierten Systeme

Mit den drei genannten Parameterkonstellationen werden nun entsprechende Classifier antrainiert und gegen den nicht-optimierten Random Forest als Referenz getestet.

In [17]:
reps = 100
clf0 = RandomForestClassifier(bootstrap=True, class_weight='balanced_subsample')
clf1 = RandomForestClassifier(bootstrap=True, class_weight='balanced_subsample', 
                              n_estimators=10,   max_depth=5,  min_samples_split=10, min_samples_leaf=3)
clf2 = RandomForestClassifier(bootstrap=True, class_weight='balanced_subsample', 
                              n_estimators=449,  max_depth=18, min_samples_split=7,  min_samples_leaf=4)
clf3 = RandomForestClassifier(bootstrap=True, class_weight='balanced_subsample', 
                              n_estimators=640, max_depth=5,  min_samples_split=6,  min_samples_leaf=1)

Important_Features = ['incident_severity_Major Damage','incident_location_street','insured_hobbies',
                      'incident_severity_Minor Damage','property_claim','total_claim_amount']

Results = []
np.random.seed(41856); Seeds = np.random.randint(0,10**6, size=reps)
for rep in range(reps):
    Train, Test     = train_test_split(DF, test_size=.3, shuffle=True, random_state=Seeds[rep])
    X_train, Y_train, X_test, Y_test = pipeline(Train, Test, feature_reduction=Important_Features)
    
    clf0.fit(X=X_train, y=Y_train)
    clf1.fit(X=X_train, y=Y_train)
    clf2.fit(X=X_train, y=Y_train)
    clf3.fit(X=X_train, y=Y_train)
    
    Predictions0 = clf0.predict(X_train)
    Predictions1 = clf1.predict(X_train)
    Predictions2 = clf2.predict(X_train)
    Predictions3 = clf3.predict(X_train)
    p0,r0,f10,s  = precision_recall_fscore_support(Y_train, Predictions0, average='binary')
    p1,r1,f11,s  = precision_recall_fscore_support(Y_train, Predictions1, average='binary')
    p2,r2,f12,s  = precision_recall_fscore_support(Y_train, Predictions2, average='binary')
    p3,r3,f13,s  = precision_recall_fscore_support(Y_train, Predictions3, average='binary')
    Results.append(['simple', 'train', p0, r0, f10])
    Results.append(['mode', 'train', p1, r1, f11])
    Results.append(['mean', 'train', p2, r2, f12])
    Results.append(['singlemax', 'train', p3, r3, f13])
    
    Predictions0 = clf0.predict(X_test)
    Predictions1 = clf1.predict(X_test)
    Predictions2 = clf2.predict(X_test)
    Predictions3 = clf3.predict(X_test)
    p0,r0,f10,s  = precision_recall_fscore_support(Y_test, Predictions0, average='binary')
    p1,r1,f11,s  = precision_recall_fscore_support(Y_test, Predictions1, average='binary')
    p2,r2,f12,s  = precision_recall_fscore_support(Y_test, Predictions2, average='binary')
    p3,r3,f13,s  = precision_recall_fscore_support(Y_test, Predictions3, average='binary')
    Results.append(['simple', 'test', p0, r0, f10])
    Results.append(['mode', 'test', p1, r1, f11])
    Results.append(['mean', 'test', p2, r2, f12])
    Results.append(['singlemax', 'test', p3, r3, f13])

ResDF = pd.DataFrame(Results, columns=['OptimisationMode', 'EvalSet', 'Precision', 'Recall', 'F1score'])
In [18]:
fig, axs = plt.subplots(ncols=3, sharey=True, figsize=(15,4))
sns.boxplot(x='OptimisationMode', y='Precision', hue='EvalSet', data=ResDF, ax=axs[0])
sns.boxplot(x='OptimisationMode', y='Recall',    hue='EvalSet', data=ResDF, ax=axs[1])
sns.boxplot(x='OptimisationMode', y='F1score',   hue='EvalSet', data=ResDF, ax=axs[2])
plt.tight_layout()
plt.show()

Wie zu erwarten war, hat die Optimierung zu einer Reduktion des Overfittings bei gleichbleibender Prediction-Performance geführt. Die Ergebnisse sind etwas schlechter als jene, die für den initialen Vergleich erzielt wurden. Dies liegt daran, dass für diesen Vergleich der gesamte Datensatz Target- und OneHot-kodiert wurde. So wurden sowohl fehlende Labels beim Fit als auch fehlende Samples für die Labelsortierung vermieden. Dies ist hier anders und führt zu einer Qualitätseinbuße, was aber auch für sämtliche andere Classifier zu erwarten gewesen wäre. Wir wählen an dieser Stelle die Modalwert-Parameter für die weitere Analyse, da bei diesen das Overfitting am geringsten ist und gleichzeitig die geringsten Computational Cost entstehen.

Neuronale Netze

Es gibt noch eine weitere Sorte von ML-Algorithmen, die bislang nicht berücksichtigt wurde: Neuronale Netze. Der Grund für diese Auslassung war die lediglich rudimentäre Implementierung mitsamt der notwendigen Steuermöglichkeiten in SciKit. Daher wechseln wir zu einem anderen ML-Framework (TensorFlow/Keras), welches eine andere Syntax als SciKit erfordert und daher nur umständlich in die obigen Analysen hätte eingefügt werden können.

Neuronale Netze bestehen aus einer Vielzahl von miteinander verbundenen Einheiten (Neuronen), von denen jede eine gewichtete Summe von Signalen aufnimmt (Input) und durch eine nichtlineare Funktion verarbeitet. Dieses verarbeitete Signal (Output) wird anschließend als Bestandteil weiterer gewichteter Summen an nachgeschaltete Neuronen weitergegeben. Diese Gewichte sind es, die die Freiheitsgrade des Systems darstellen und durch Optimierungsverfahren angelernt werden. Einen ersten Gesamtüberblick über neuronale Netze erhält man unter https://en.wikipedia.org/wiki/Artificial_neural_network.

Neben den Gewichtungen gibt es eine Vielzahl weiterer Parameter, die nicht angelernt werden können. Diese werden auch Hyperparameter genannt. Einige Beispiele hierfür sind

  • Anzahl von Neuronen und Neuronenschichten (Layer)
  • Activation Functions
  • Learning Rate
  • Optimierungsalgorithmen (Gradient Descent, Adam, Momentum, etc.)
  • Initialisierungsalgorithmen
  • Anzahl von Epochen
  • uvm.

Aus diesem Grund ist die Optimierung von neuronalen Netzen eher experimentell und würde den Rahmen dieses Notebooks sprengen. Im Folgenden präsentieren wir das optimierte neuronale Netz, welches in mehreren Iterationsschritten erarbeitet wurde. Dieses Netz besteht aus einem Input-Layer mit fünf Neuronen, einem sogenannten Hidden Layer mit zwei Neuronen und einem Output-Layer mit einem Neuron. Dieses eine Neuron besitzt eine Sigmoid-Funktion als Aktivierung, sodass der Output Zahlenwerte zwischen 0 und 1 liefert und somit eine Interpretations als Wahrscheinlichkeit zulassen. Zwischen den Layern verwenden wir Dropout, das bei jedem Iterationsschritt neuronale Verbindungen per Zufall trennt. Somit wirken wir dem Overfitting entgegen.

In [ ]:
reps = 100
Results = []
np.random.seed(41856); Seeds = np.random.randint(0,10**6, size=reps)
for rep in range(reps):

    model = Sequential()
    model.add(Dense(units = 5, activation='tanh'))
    model.add(Dropout(0.3))
    model.add(Dense(units = 2, activation='tanh'))
    model.add(Dropout(0.3))
    model.add(Dense(units = 1, activation='sigmoid'))   
    model.compile(loss='binary_crossentropy', optimizer=SGD(learning_rate=0.001, momentum=0.9), metrics=['accuracy'])
    
    Train, Test = train_test_split(DF, test_size=.3, shuffle=True, random_state=Seeds[rep])
    X_train, Y_train, X_test, Y_test = pipeline(Train, Test, feature_reduction=Important_Features)
    
    model.fit(X_train.to_numpy(), Y_train.to_numpy(), batch_size=64, nb_epoch=100, class_weight={0:1., 1:3.}, verbose=0)
    
    Predictions = model.predict_classes(X_train.to_numpy())
    p,r,f1,s    = precision_recall_fscore_support(Y_train, Predictions, average='binary')
    Results.append(['KerasDeepL', 'train', p, r, f1])
    
    Predictions = model.predict_classes(X_test.to_numpy())
    p,r,f1,s    = precision_recall_fscore_support(Y_test, Predictions, average='binary')
    Results.append(['KerasDeepL', 'test', p, r, f1])

ResDF_keras = pd.DataFrame(Results, columns=['OptimisationMode', 'EvalSet', 'Precision', 'Recall', 'F1score'])
In [21]:
FinalResults = pd.concat([ResDF[ResDF.OptimisationMode=='mode'], ResDF_keras], ignore_index=True)
FinalResults = FinalResults.rename(columns={"OptimisationMode": "Model"}).replace('mode', 'RandomForest')

fig, axs = plt.subplots(ncols=3, sharey=True, figsize=(15,4))
sns.boxplot(x='Model', y='Precision', hue='EvalSet', data=FinalResults, ax=axs[0])
sns.boxplot(x='Model', y='Recall',    hue='EvalSet', data=FinalResults, ax=axs[1])
sns.boxplot(x='Model', y='F1score',   hue='EvalSet', data=FinalResults, ax=axs[2])
plt.tight_layout()
plt.show()

Im Vergleich zeigt sich, dass das neuronale Netz einen höheren Recall auf dem Test-Set bei geringerem Overfitting bietet als der RF. Gleichzeitig ist die Precision geringer als beim RF. Dies ist ein Zeichen dafür, dass das neurale Netz wesentlich verhaltener ist und sich im Zweifel für einen Betrug entscheidet, auch wenn keiner vorliegt. Aus diesem Grund ist der F1-Score auch geringer als beim RF. Tendenziell ist ein vorsichtigeres System mit höherem Recall aber zu bevorzugen. Die als Betrug gekennzeichneten Fälle werden ohnehin noch einmal einer menschlichen Prüfung unterzogen. Daher ist ein hoher Recall wichtiger als eine hohe Precision.

4. Monetäre Aspekte

Nun wollen wir eine weitere Metrik untersuchen und prüfen, wie viel Geld uns das beide Systeme sparen können. Hierzu betrachten wir die folgenden Fälle:

  • Saved Money ist die Höhe der Claims, die korrekterweise als Betrug identifiziert wurden.
  • Lost Money ist die Höhe der Claims, die fälschlicherweise nicht als Betrug identifiziert wurden.
  • Unnecessary ist die Anzahl der Fälle, die fälschlicherweise als Betrug identifiziert wurden und eine unnötige Fallprüfung durch Mitarbeiter notwendig machen würden.
In [28]:
reps = 200
clf  = RandomForestClassifier(bootstrap=True, class_weight='balanced_subsample', 
                              n_estimators=10,   max_depth=5,  min_samples_split=10, min_samples_leaf=3)

Important_Features = ['incident_severity_Major Damage','incident_location_street','insured_hobbies',
                      'incident_severity_Minor Damage','property_claim','total_claim_amount']

np.random.seed(41856); Seeds = np.random.randint(0,10**6, size=reps)
Results = [] ; Unnecessary = []
for rep in range(reps):
    Train, Test = train_test_split(DF, test_size=.3, shuffle=True, random_state=Seeds[rep])
    X_train, Y_train, X_test, Y_test = pipeline(Train, Test, feature_reduction=Important_Features)
    
    clf.fit(X_train, Y_train)
    RF_predicts = clf.predict(X_test)
    
    RF_Money = pd.DataFrame({'claim':Test['total_claim_amount'].to_numpy(),
                            'fraud_reported':Y_test.to_numpy(),
                            'fraud_predicted':RF_predicts})
    Saved = RF_Money[(RF_Money.fraud_predicted==1)&(RF_Money.fraud_reported==1)]
    Lost  = RF_Money[(RF_Money.fraud_predicted==0)&(RF_Money.fraud_reported==1)]
    Unnec = RF_Money[(RF_Money.fraud_predicted==1)&(RF_Money.fraud_reported==0)]

    Results.append(['RandomForest', 'saved', np.sum(Saved.claim)])
    Results.append(['RandomForest', 'lost', np.sum(Lost.claim)])
    Unnecessary.append(['RandomForest', 100*len(Unnec)/len(RF_Money)])
        
    model = Sequential()
    model.add(Dense(units = 5, activation='tanh'))
    model.add(Dropout(0.3))
    model.add(Dense(units = 2, activation='tanh'))
    model.add(Dropout(0.3))
    model.add(Dense(units = 1, activation='sigmoid'))   
    model.compile(loss='binary_crossentropy', optimizer=SGD(learning_rate=0.001, momentum=0.9), metrics=['accuracy'])
    
    model.fit(X_train.to_numpy(), Y_train.to_numpy(), batch_size=64, nb_epoch=100, class_weight={0:1., 1:3.}, verbose=0)
    NN_predicts = model.predict_classes(X_test.to_numpy())
    
    NN_Money = pd.DataFrame({'claim':Test['total_claim_amount'].to_numpy(),
                            'fraud_reported':Y_test.to_numpy(),
                            'fraud_predicted':NN_predicts.flatten()})
    Saved = NN_Money[(NN_Money.fraud_predicted==1)&(NN_Money.fraud_reported==1)]
    Lost  = NN_Money[(NN_Money.fraud_predicted==0)&(NN_Money.fraud_reported==1)]
    Unnec = NN_Money[(NN_Money.fraud_predicted==1)&(NN_Money.fraud_reported==0)]

    Results.append(['KerasDeep', 'saved', np.sum(Saved.claim)])
    Results.append(['KerasDeep', 'lost', np.sum(Lost.claim)])
    Unnecessary.append(['KerasDeep', 100*len(Unnec)/len(NN_Money)])

MonDF = pd.DataFrame(Results, columns=['model', 'saved_lost', 'money'])
UnnDF = pd.DataFrame(Unnecessary, columns=['model', 'percentage'])
In [29]:
fig, axarr = plt.subplots(ncols=2, figsize=(15,4))
sns.boxplot(x='model', y='money', hue='saved_lost', data=MonDF, ax=axarr[0])
sns.boxplot(x='model', y='percentage', data=UnnDF, ax=axarr[1])
axarr[0].set_title('Money Saved / Lost')
axarr[1].set_title('Unnecessary Suspicion')
plt.tight_layout()
plt.show()

Erkenntnisse:

Die Vorsicht des neuronalen Netzes wird auch hier offensichtlich. Da mehr Fälle als Betrug markiert werden, liegt der Prozentsatz der unnötigen Überprüfungen etwa bei 15%, während er beim RF bei etwa 7.5% liegt. Gleichzeitig zeigt sich, dass ein vorsichtigeres System (auch wenn es doppelt so viel unnötige Arbeit macht) Verluste vermehrt vermeidet. Insgesamt erkennen wir:

  • Bei Verwendung des Random Forest

    • Betrugsfälle im Wert von 2.5 bis 3 Mio. USD können direkt als solche identifiziert werden
    • Betrugsfälle im Wert von 1.5 bis 2 Mio. USD werden nicht erkannt
    • Die Rate der falsch markierten Betrugsfälle (unnötige Arbeit beim anschließenden Prüfen) liegt bei etwa 7.5%
  • Bei Verwendung des neuronalen Netzes

    • Betrugsfälle im Wert von 3 bis 3.5 Mio. USD können direkt als solche identifiziert werden
    • Betrugsfälle im Wert von 1.2 bis 1.7 Mio. USD werden nicht erkannt
    • Die Rate der falsch markierten Betrugsfälle (unnötige Arbeit beim anschließenden Prüfen) liegt bei etwa 15%

Das vorsichtigere neuronale Netz erkennt jährlich Betrugsfälle im Wert von 500.000 USD mehr als der RF, liefert aber doppelt so viele unnötige Prüffälle. Welches System nun letztlich als Fraud Detector implementiert werden sollte, kann nur individuell mit der jeweiligen Versicherung entschieden werden. Hier kommt es auf die Präferenzen an: Auf 500.000 USD verzichten und eine (weitere) Entlastung der Mitarbeiter gewinnen, oder eine Mehrbelastung in Kauf nehmen und 500.000 USD hinzugewinnen. Auch ist hier individuell zu prüfen, wie viel ein unnötiger Prüffall im Durchschnitt kostet (Personentage x Tagessatz) und ob die Zusatzarbeit des NN gegenüber dem RF wirtschaftlich mit dem erzielten Zusatzgewinn die Waage hält.

Abschließend wäre es für ein Versicherungsunternehmen interessant, die Erkennungsrate der bereits implementierten Kfz-Betrugsprüfung zu kennen, um einen realistischen Vergleich zu den hier erzielten Resultaten anstellen zu können und dem trivialen Ansatz ("keine ML-Betrugsprüfung") zu vergleichen.

5. Zusammenfassung

Data Science eröffnet viele Potenziale zur Verbesserung in Versicherungen, bspw. in Form von stärker automatisierten Prozessen (wie hier zur Aufdeckung von potenziellen Betrugsfällen), im Bereich Predictive Analytics oder auch im Pricing. Allerdings lassen sich Data Science und aktuarielles Know-How sich für eine zielführende Arbeit in der Versicherungsbranche nicht voneinander trennen. Das Ziel muss sein, dass Aktuare das Thema Data Science innerhalb der Versicherungen vorantreiben, damit ein ganzheitlicher "alles aus einer Hand"-Ansatz ermöglicht wird. Dies wird niemals vollständig alleine geschehen, da viele weitere Entscheidungsfelder angeschnitten werden (insbesondere aus dem Management), aber das aktuarielle Betätigungsfeld und Data Science liegen "nah genug" beieinander, um die möglichen Synergieeffekte abzuschöpfen - insbesondere in dem Wissen, dass Data Science allein keine tragfähigen Ergebnisse produzieren wird. In diesem Sinne wird das sich auftuende Betätigungsfeld prototypisch für die Zukunft sein: Eine kleine Anzahl extrem hochqualifizierter Mitarbeiter werden die komplexen Aufgabenstellungen bewältigen müssen, während immer mehr weniger komplexe Aufgaben "in die Maschine" gegeben werden.

In [ ]: